mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061) (#2303)
* fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061)
numericInput converted a float64 with int64(x), so limit 1.5 read as 1, -2.7
as -2, and a value outside int64 as an implementation-defined number. A
json.Number that did not parse read as absent, so the parameter was dropped.
- numericInput returns an error for a fraction, a value outside int64 (2^63
is out), and an unparseable json.Number. intParam names the parameter.
- All 10 caller-input sites refuse with validation_failed: the route mappers,
item history, attachment list, and the pad_item.list and pad_item.history
catalog actions. pad_item.list's documented truncation of a fractional
limit to the default is reversed by ruling; zero still means the default.
- actionItemGet's read of the server's own seq stays lenient (pinned).
- itemExpectedSeqParam refuses a float64 outside int64 and gets the same
json.Number refusal, rather than dropping the token (an unguarded write).
- Comments note the unreachable float32 paths in formatNumber and
chatSequence.
- ToolSurfaceVersion 0.103: the parameters are declared "number", so the
refused inputs were valid under the advertised shape. Goldens move only by
the version string.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): buildQuery refuses a number that is not a whole int64 (BUG-4061 r1)
Round 1 (Codex Medium, and the hunter's standup/changelog question): the
query-string doors built by buildQuery (item backlinks, item context budget,
project activity, item search, project standup and changelog) forwarded
"1.5", and the server parsers read it as absent and used their default.
buildQuery now refuses with the key named, and its four callers return
validation_failed (lead ruling on Q2, option (a)).
- TestBUG4056BuildQueryNumbersUnchanged: the 2.5 and 1e19 rows are now
refusals, superseded by that ruling. The notation rows stand, and
json.Number "1e6" forwards as "1000000".
- jsonNumberInt64: a whole json.Number in exponent form ("1e6") is
accepted. Int64() alone refused it. Used by numericInput and
itemExpectedSeqParam.
- The v0.103 changelog door list is corrected. The instructions.md and
README version headings follow 0.103, which 54fc02855 missed. Goldens
move only by the instructions heading.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): read a json.Number exactly, and envelope a refused expected_seq (BUG-4061 r2)
- jsonNumberInt64 fell back to Float64, which rounds: 9007199254740993e0
became ...992 and 1.0000000000000001e0 became 1, changing the caller's
number again (Codex r2 Medium, hunter r2 Low). It now parses exactly with
big.Rat. A length bound (64) and an exponent bound (|e| <= 64) keep a
literal like 1e999999999 from costing a huge allocation.
- dispatchItemUpdate returned a refused expected_seq as a raw error, which
the transport surfaces as a protocol failure. It is now a validation_failed
envelope, as every other refused parameter is and as the v0.103 entry says
(hunter r2 Medium). The fraction half predates this bug.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): refuse a float64 integer above 2^53, which the decoder has already rounded (BUG-4061 r3)
Codex r3 Medium: the MCP JSON decoder turns expected_seq 9007199254740993
into float64 9007199254740992 before any check runs, so a whole, in-range
value was forwarded as the wrong number. wholeInt64 now refuses |x| > 2^53
("beyond the exact integer range of a JSON number"). This applies to the
float64 path only, per the lead ruling on Q3. A json.Number is parsed exactly
and keeps the full int64 range. chatSequence and formatNumber are unchanged;
the chat seq limit is tracked on BUG-4065.
Also corrects the chatSequence comment (hunter r3 L3): a json.Number in
exponent form is refused there, not parsed.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): refuse a float64 of magnitude 2^53 or more, not only above it (BUG-4061 r4)
Hunter r4 Low, ruled a fix by the lead's correction of Q3: 2^53 is the
rounded image of 2^53+1, so a float64 2^53 cannot prove it is the number
sent either. wholeInt64 now refuses |x| >= 2^53, so the largest accepted
float64 is 2^53-1, the safe-integer bound. json.Number keeps the exact
int64 range. Test rows: 2^53-1 accepted, 2^53 and -2^53 refused.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): read a json.Number only if it is a plain decimal literal (BUG-4061 r5)
Hunter r5 L1, ruled a fix: jsonNumberInt64 filtered big.Rat-only syntax
with a deny-list ("/xXpP"), which missed the 0b and 0o prefixes, so a
hand-built json.Number "0b101" was read as 5. An allow-list,
isDecimalLiteral, now admits a sign, digits with at most one '.', and at
most one e/E exponent with an optional sign. Refusal rows: 0b101, 0o17,
0x10, 1_000, 4/2. 12e+0 is accepted.
Hunter r5 L2: the standup and changelog refusal hint is neutral ("Pass
numeric parameters as whole numbers.") instead of naming days for any key.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* test(mcp): cover the remaining query doors and the lenient seq at 2^53 (BUG-4061 r7)
Test rows and one comment, no runtime change (lead ruling on Q7):
- Door rows through Dispatch: item context budget and project activity
limit (the generic route mapper's other numeric query keys), and a
json.Number limit on item list (h2 L1).
- actionItemGet's lenient server-seq read at float64 2^53 leaves Seq zero
(h2 L2).
- chatSequence's comment says a float32 or exponent-form json.Number
producer should be routed through numericInput's check, and that this
path does not do that today (Codex r7 Low).
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* test(mcp): the door table requires the refusal clause to start with the key (BUG-4061 r8 Q8)
The door table matched a bare "<param> " substring, which the hint text
also satisfies on the two doors whose hints repeat the key (expected_seq,
item history's limit). A door naming the wrong key in its message passed.
The check now requires ": <param> must be " or ": <param> is ", the
validationFailedResult rendering of "<cmdKey>: <msg>".
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* test(mcp): pin expected_seq's 2^53 bound and the exact fraction refusal; narrow the float32 note (BUG-4061 r9)
- itemExpectedSeqParam's float64 branch had no 2^53 row of its own, so a
looser conversion there passed. Rows for 2^53-1 (accepted) and ±2^53.
- The json.Number fraction-past-float64-precision row asserted a prefix the
"in range" refusal also has; it now asserts the fraction refusal itself.
- formatNumber's float32 note: no non-test code passes a float32, but its
own tests do.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
This commit is contained in:
@@ -402,7 +402,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
|
||||
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
|
||||
the per-user clients (including Codex) and skip it.
|
||||
|
||||
**Tool catalog (v0.102)** — eleven resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
**Tool catalog (v0.103)** — eleven resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
|
||||
| Tool | Actions |
|
||||
|---|---|
|
||||
@@ -427,7 +427,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
|
||||
and `pad://_meta/version`.
|
||||
|
||||
**Stability contract.** The catalog is versioned
|
||||
(`tool_surface_version: "0.102"`) and advertised in the initialize handshake and at
|
||||
(`tool_surface_version: "0.103"`) and advertised in the initialize handshake and at
|
||||
`pad://_meta/version`, so external agents can pin against it; the per-version
|
||||
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
|
||||
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -69,12 +69,14 @@ func TestBUG3704_PadItemListSaysWhenItIsTruncated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A fractional limit under 1 takes the default, as zero does (it used to,
|
||||
// through the CLI's own default; the probe row must not turn it into 1).
|
||||
func TestBUG3704_PadItemListFractionalLimitTakesTheDefault(t *testing.T) {
|
||||
// A zero limit takes the default (it used to, through the CLI's own default;
|
||||
// the probe row must not turn it into 1). A FRACTIONAL limit under 1 used to
|
||||
// take the default the same way, by truncation; BUG-4061 refuses it instead
|
||||
// (TestBUG4061_CatalogLimitRefusesAFraction).
|
||||
func TestBUG3704_PadItemListZeroLimitTakesTheDefault(t *testing.T) {
|
||||
disp := &listRowsDispatcher{rows: 3}
|
||||
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: disp}
|
||||
if _, err := actionItemList(context.Background(), map[string]any{"limit": float64(0.5)}, env); err != nil {
|
||||
if _, err := actionItemList(context.Background(), map[string]any{"limit": float64(0)}, env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(disp.gotArgs, " "), "--limit 51") {
|
||||
|
||||
@@ -13,23 +13,35 @@ import (
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// BUG-4056 pinned notation: an integral number forwards without an exponent.
|
||||
// BUG-4061 (lead ruling on its Q2, day 33) superseded the rows that forwarded
|
||||
// a fraction and a value outside int64 unchanged: every numeric query
|
||||
// parameter is an integer and the server's parsers default a malformed one,
|
||||
// so those two are now refusals naming the key. The notation rows stand.
|
||||
func TestBUG4056BuildQueryNumbersUnchanged(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{"integral", float64(1000000), "1000000"},
|
||||
{"large integral", float64(1e19), "10000000000000000000"},
|
||||
{"fractional", 2.5, "2.5"},
|
||||
{"negative zero", math.Copysign(0, -1), "0"},
|
||||
{"json number", json.Number("1e6"), "1e6"},
|
||||
{name: "integral", in: float64(1000000), want: "1000000"},
|
||||
{name: "large integral outside int64", in: float64(1e19), wantErr: "n is out of range"},
|
||||
{name: "fractional", in: 2.5, wantErr: "n must be a whole number, got 2.5"},
|
||||
{name: "negative zero", in: math.Copysign(0, -1), want: "0"},
|
||||
{name: "json number", in: json.Number("1e6"), want: "1000000"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
query := buildQuery(map[string]any{"n": tc.in}, map[string]string{"n": "n"})
|
||||
query, qerr := buildQuery(map[string]any{"n": tc.in}, map[string]string{"n": "n"})
|
||||
if tc.wantErr != "" {
|
||||
if qerr == nil || !strings.Contains(qerr.Error(), tc.wantErr) {
|
||||
t.Fatalf("buildQuery = %q, %v; want a refusal containing %q", query, qerr, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
values, err := url.ParseQuery(query)
|
||||
if err != nil || values.Get("n") != tc.want {
|
||||
t.Fatalf("buildQuery = %q, %v; want n=%q", query, err, tc.want)
|
||||
if qerr != nil || err != nil || values.Get("n") != tc.want {
|
||||
t.Fatalf("buildQuery = %q, %v, %v; want n=%q", query, qerr, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// BUG-4061: a number that is not a whole int64 is refused, never changed:
|
||||
// int64(1.5) is 1, int64(-2.7) is -2, int64(1e19) is implementation-defined,
|
||||
// and a json.Number that does not parse used to read as absent.
|
||||
func TestBUG4061_NumericInputRefusesWhatItWouldChange(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want int64
|
||||
present bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "whole float64", in: float64(42), want: 42, present: true},
|
||||
{name: "negative whole float64", in: float64(-3), want: -3, present: true},
|
||||
{name: "fraction", in: 1.5, wantErr: "must be a whole number, got 1.5"},
|
||||
{name: "negative fraction", in: -2.7, wantErr: "must be a whole number, got -2.7"},
|
||||
{name: "above int64", in: 1e19, wantErr: "is out of range"},
|
||||
{name: "below int64", in: -1e19, wantErr: "is out of range"},
|
||||
{name: "2^63 is out of range", in: float64(1 << 63), wantErr: "is out of range"},
|
||||
// r3/r4 (lead ruling on Q3, corrected on r4): from 2^53 up, a float64
|
||||
// may already be rounded; 2^53 is the image of 2^53+1.
|
||||
{name: "float64 2^53-1 is accepted", in: float64(1<<53 - 1), want: 1<<53 - 1, present: true},
|
||||
{name: "float64 -(2^53-1) is accepted", in: -float64(1<<53 - 1), want: -(1<<53 - 1), present: true},
|
||||
{name: "float64 2^53 is refused", in: float64(1 << 53), wantErr: "is beyond the exact integer range of a JSON number"},
|
||||
{name: "float64 -2^53 is refused", in: -float64(1 << 53), wantErr: "is beyond the exact integer range of a JSON number"},
|
||||
{name: "float64 2^53+2 is refused", in: float64(1<<53 + 2), wantErr: "is beyond the exact integer range of a JSON number"},
|
||||
{name: "float64 -2^63 is refused", in: -float64(1 << 63), wantErr: "is beyond the exact integer range"},
|
||||
{name: "json.Number 2^53+1 is exact", in: json.Number("9007199254740993"), want: 9007199254740993, present: true},
|
||||
{name: "int", in: 7, want: 7, present: true},
|
||||
{name: "int64", in: int64(8), want: 8, present: true},
|
||||
{name: "json.Number whole", in: json.Number("42"), want: 42, present: true},
|
||||
{name: "json.Number fraction", in: json.Number("1.5"), wantErr: "got 1.5"},
|
||||
{name: "json.Number above int64", in: json.Number("1e19"), wantErr: "is out of range"},
|
||||
{name: "json.Number whole with an exponent", in: json.Number("1e6"), want: 1000000, present: true},
|
||||
{name: "json.Number not a number", in: json.Number("abc"), wantErr: "must be a whole number in range, got abc"},
|
||||
// r2: exact, not via float64, which would round these two.
|
||||
{name: "json.Number 2^53+1 with an exponent", in: json.Number("9007199254740993e0"), want: 9007199254740993, present: true},
|
||||
// r9 (h2 F1): the fraction refusal, not the "in range" one it prefixes.
|
||||
{name: "json.Number a fraction float64 rounds to 1", in: json.Number("1.0000000000000001e0"), wantErr: "must be a whole number, got 1.0000000000000001e0"},
|
||||
{name: "json.Number huge exponent", in: json.Number("1e999999999"), wantErr: "in range"},
|
||||
{name: "json.Number 1e64 is out of range", in: json.Number("1e64"), wantErr: "is out of range"},
|
||||
// r5: big.Rat syntax that is not a JSON number is refused, not parsed.
|
||||
{name: "json.Number binary prefix", in: json.Number("0b101"), wantErr: "must be a whole number in range, got 0b101"},
|
||||
{name: "json.Number octal prefix", in: json.Number("0o17"), wantErr: "must be a whole number in range, got 0o17"},
|
||||
{name: "json.Number hex prefix", in: json.Number("0x10"), wantErr: "must be a whole number in range, got 0x10"},
|
||||
{name: "json.Number underscore", in: json.Number("1_000"), wantErr: "must be a whole number in range, got 1_000"},
|
||||
{name: "json.Number fraction syntax", in: json.Number("4/2"), wantErr: "must be a whole number in range, got 4/2"},
|
||||
{name: "json.Number signed exponent", in: json.Number("12e+0"), want: 12, present: true},
|
||||
{name: "nil is absent", in: nil},
|
||||
{name: "a string is absent, as before", in: "10"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, present, err := numericInput(tc.in)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("numericInput(%#v) = %d, %v, %v; want a refusal containing %q", tc.in, got, present, err, tc.wantErr)
|
||||
}
|
||||
if present {
|
||||
t.Fatal("a refused value reported present")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != tc.want || present != tc.present {
|
||||
t.Fatalf("numericInput(%#v) = %d, %v, %v; want %d, %v, nil", tc.in, got, present, err, tc.want, tc.present)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, err := intParam(map[string]any{"limit": 1.5}, "limit"); err == nil || err.Error() != "limit must be a whole number, got 1.5" {
|
||||
t.Fatalf("intParam does not name the parameter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-4061 through the remote dispatcher: every door that reads a numeric
|
||||
// parameter refuses a fraction with a validation error naming it, and the
|
||||
// request never reaches the handler. One row per (door, parameter), covering
|
||||
// both refusal paths: a route mapper (validationFailedResult in Dispatch) and
|
||||
// a special dispatch function (its own validationFailedResult).
|
||||
func TestBUG4061_EveryDispatchDoorRefusesAFractionNamingIt(t *testing.T) {
|
||||
cases := []struct {
|
||||
cmd []string
|
||||
input map[string]any
|
||||
param string
|
||||
// readsFirst: the door reads the item before it validates, so a GET
|
||||
// may reach the handler; no write may.
|
||||
readsFirst bool
|
||||
}{
|
||||
{[]string{"attachment", "list"}, map[string]any{"limit": 1.5}, "limit", false},
|
||||
{[]string{"attachment", "list"}, map[string]any{"offset": 2.5}, "offset", false},
|
||||
{[]string{"item", "history"}, map[string]any{"ref": "TASK-1", "limit": 1.5}, "limit", false},
|
||||
{[]string{"item", "starred"}, map[string]any{"limit": 1.5}, "limit", false},
|
||||
{[]string{"workspace", "audit-log"}, map[string]any{"days": 1.5}, "days", false},
|
||||
{[]string{"workspace", "audit-log"}, map[string]any{"limit": 1e19}, "limit", false},
|
||||
{[]string{"item", "list"}, map[string]any{"limit": -2.7}, "limit", false},
|
||||
{[]string{"item", "list"}, map[string]any{"offset": 1.5}, "offset", false},
|
||||
// The four buildQuery callers (lead ruling on Q2): the generic route
|
||||
// mapper, mapItemSearch, and the standup and changelog dispatchers.
|
||||
{[]string{"item", "backlinks"}, map[string]any{"ref": "TASK-1", "limit": 1.5}, "limit", false},
|
||||
{[]string{"item", "search"}, map[string]any{"query": "x", "offset": 2.5}, "offset", false},
|
||||
{[]string{"project", "standup"}, map[string]any{"days": 0.5}, "days", false},
|
||||
{[]string{"project", "changelog"}, map[string]any{"days": 1e19}, "days", false},
|
||||
// r2: expected_seq used to come back as a raw error, not an envelope.
|
||||
{[]string{"item", "update"}, map[string]any{"ref": "TASK-1", "content": "x", "expected_seq": 1.5}, "expected_seq", true},
|
||||
// r7 (h2 L1): the generic route mapper's other numeric query keys, and
|
||||
// a json.Number through Dispatch rather than at the unit level.
|
||||
{[]string{"item", "context"}, map[string]any{"ref": "TASK-1", "budget": 1.5}, "budget", false},
|
||||
{[]string{"project", "activity"}, map[string]any{"limit": 2.5}, "limit", false},
|
||||
{[]string{"item", "list"}, map[string]any{"limit": json.Number("1.5")}, "limit", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(strings.Join(tc.cmd, " ")+" "+tc.param, func(t *testing.T) {
|
||||
var reached string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if tc.readsFirst && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
reached = r.Method + " " + r.URL.RequestURI()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
||||
input := map[string]any{"workspace": "docapp"}
|
||||
for k, v := range tc.input {
|
||||
input[k] = v
|
||||
}
|
||||
res, err := d.Dispatch(WithDispatchInput(context.Background(), input), tc.cmd, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res == nil || !res.IsError {
|
||||
t.Fatalf("not refused; the request reached %q", reached)
|
||||
}
|
||||
// The refusal clause must START with the key: the message renders as
|
||||
// "<cmdKey>: <param> must be ...". A bare "<param> " substring also
|
||||
// matched the hint (expected_seq's "Pass expected_seq as ..."), so it
|
||||
// could not see a door naming the wrong key (BUG-4061 r8, Q8).
|
||||
txt := textOf(res)
|
||||
if !strings.Contains(txt, ": "+tc.param+" must be ") && !strings.Contains(txt, ": "+tc.param+" is ") {
|
||||
t.Fatalf("refusal clause does not start with %q: %s", tc.param, txt)
|
||||
}
|
||||
if !strings.Contains(txt, string(ErrValidationFailed)) {
|
||||
t.Fatalf("refusal is not a validation failure: %s", txt)
|
||||
}
|
||||
if reached != "" {
|
||||
t.Fatalf("a refused input reached the handler at %q", reached)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-4061 at the catalog doors: pad_item.list (whose limit used to truncate a
|
||||
// fraction to the default by design, ruled refuse) and pad_item.history.
|
||||
func TestBUG4061_CatalogLimitRefusesAFraction(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
act func(context.Context, map[string]any, ActionEnv) (*CallToolResult, error)
|
||||
}{
|
||||
{"pad_item.list", actionItemList},
|
||||
{"pad_item.history", actionItemHistory},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
disp := &fakeDispatcher{}
|
||||
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: disp}
|
||||
res, err := tc.act(context.Background(), map[string]any{"ref": "TASK-1", "limit": 0.5}, env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res == nil || !res.IsError || !strings.Contains(textOf(res), tc.name+": limit must be a whole number, got 0.5") {
|
||||
t.Fatalf("expected a refusal naming limit, got %#v", res)
|
||||
}
|
||||
if len(disp.gotPath) != 0 {
|
||||
t.Fatalf("a refused limit reached the dispatcher: %v", disp.gotPath)
|
||||
}
|
||||
// Zero still means the default: refusing fractions changes nothing else.
|
||||
if res, err := tc.act(context.Background(), map[string]any{"ref": "TASK-1", "limit": float64(0)}, env); err != nil || res.IsError {
|
||||
t.Fatalf("limit 0 was refused: %v %s", err, textOf(res))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// structuredDispatcher answers every dispatch with a fixed structured body.
|
||||
type structuredDispatcher struct{ body map[string]any }
|
||||
|
||||
func (s structuredDispatcher) Dispatch(context.Context, []string, []string) (*CallToolResult, error) {
|
||||
return &mcp.CallToolResult{StructuredContent: s.body}, nil
|
||||
}
|
||||
|
||||
// BUG-4061 site 5: the lines view reads the SERVER's seq, not caller input,
|
||||
// so it stays lenient (ruled not applicable): absent, nil or malformed leaves
|
||||
// Seq zero rather than failing the read.
|
||||
func TestBUG4061_LinesViewReadsTheServerSeqLeniently(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
seq any
|
||||
want int64
|
||||
}{
|
||||
{"absent", nil, 0},
|
||||
{"whole", float64(17), 17},
|
||||
{"fraction", 1.5, 0},
|
||||
// r7 (h2 L2): a float64 seq of magnitude 2^53 or more is refused by
|
||||
// wholeInt64, and the lenient read leaves Seq zero, as for a fraction.
|
||||
{"at 2^53", float64(1 << 53), 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := map[string]any{"ref": "TASK-1", "content": "a\nb\n"}
|
||||
if tc.seq != nil {
|
||||
body["seq"] = tc.seq
|
||||
}
|
||||
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: structuredDispatcher{body: body}}
|
||||
res, err := actionItemGet(context.Background(), map[string]any{"ref": "TASK-1", "lines": "1-2"}, env)
|
||||
if err != nil || res.IsError {
|
||||
t.Fatalf("lines read failed: %v %s", err, textOf(res))
|
||||
}
|
||||
b, _ := json.Marshal(res.StructuredContent)
|
||||
var view struct {
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &view)
|
||||
if view.Seq != tc.want {
|
||||
t.Fatalf("seq = %d, want %d", view.Seq, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-4061 B: expected_seq outside int64, or as a json.Number, gets the same
|
||||
// refusal as a fraction rather than an implementation-defined token or an
|
||||
// unguarded write.
|
||||
func TestBUG4061_ExpectedSeqRangeAndJSONNumber(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
v any
|
||||
want int64
|
||||
wantErr string
|
||||
}{
|
||||
{"above int64", 1e19, 0, "expected_seq is out of range"},
|
||||
// r9 (h2 F4): the float64 path's own bound, not only the unit-level one.
|
||||
{"float64 2^53-1 is accepted", float64(1<<53 - 1), 1<<53 - 1, ""},
|
||||
{"float64 2^53 is refused", float64(1 << 53), 0, "expected_seq is beyond the exact integer range of a JSON number"},
|
||||
{"float64 -2^53 is refused", -float64(1 << 53), 0, "expected_seq is beyond the exact integer range of a JSON number"},
|
||||
{"json.Number whole", json.Number("42"), 42, ""},
|
||||
{"json.Number fraction", json.Number("42.7"), 0, "expected_seq must be a whole number, got 42.7"},
|
||||
{"json.Number whole with an exponent", json.Number("4.2e1"), 42, ""},
|
||||
{"json.Number above int64", json.Number("1e19"), 0, "expected_seq is out of range"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, present, err := itemExpectedSeqParam(map[string]any{"expected_seq": tc.v})
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || present {
|
||||
t.Fatalf("got %d, %v, %v; want a refusal containing %q", got, present, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || !present || got != tc.want {
|
||||
t.Fatalf("got %d, %v, %v; want %d present", got, present, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -798,9 +798,14 @@ func actionItemList(ctx context.Context, input map[string]any, env ActionEnv) (*
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
// Whole rows: a fractional limit under 1 takes the default, as zero does.
|
||||
// A limit of zero or less takes the default. A fractional one is refused,
|
||||
// not truncated (BUG-4061): 0.5 used to become 0, and so the default.
|
||||
limit := mcpItemListDefaultLimit
|
||||
if n, ok := numericInput(input["limit"]); ok && int(n) > 0 {
|
||||
n, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return errStructured("pad_item.list", err), nil
|
||||
}
|
||||
if ok && int(n) > 0 {
|
||||
limit = int(n)
|
||||
}
|
||||
if limit > mcpItemListMaxLimit {
|
||||
@@ -892,7 +897,11 @@ func actionItemHistory(ctx context.Context, input map[string]any, env ActionEnv)
|
||||
}
|
||||
|
||||
limit := mcpItemHistoryDefaultLimit
|
||||
if n, ok := numericInput(input["limit"]); ok && n > 0 {
|
||||
n, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return errStructured("pad_item.history", err), nil
|
||||
}
|
||||
if ok && n > 0 {
|
||||
limit = int(n)
|
||||
}
|
||||
if limit > mcpItemHistoryMaxLimit {
|
||||
@@ -1022,8 +1031,10 @@ func actionItemGet(ctx context.Context, input map[string]any, env ActionEnv) (*C
|
||||
lines, from, to, total := bodypatch.SliceBodyLines(content, start, end)
|
||||
view := bodypatch.BodyLines{Start: from, End: to, TotalLines: total, Lines: lines}
|
||||
view.Ref, _ = m["ref"].(string)
|
||||
if n, ok := numericInput(m["seq"]); ok {
|
||||
view.Seq = int64(n)
|
||||
// The server's own seq, not caller input: read leniently, so an absent
|
||||
// or malformed one leaves Seq zero (BUG-4061 ruled it out of scope).
|
||||
if n, ok, _ := numericInput(m["seq"]); ok {
|
||||
view.Seq = n
|
||||
}
|
||||
view.ContentState, _ = m["content_state"].(string)
|
||||
text, _ := json.Marshal(view)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -431,7 +430,10 @@ func (d *HTTPHandlerDispatcher) dispatchItemUpdate(
|
||||
// contention.
|
||||
seq, hasSeq, seqErr := itemExpectedSeqParam(input)
|
||||
if seqErr != nil {
|
||||
return nil, seqErr
|
||||
// BUG-4061 r2: a validation_failed envelope, as every other refused
|
||||
// parameter gets, not a raw error the transport turns into a
|
||||
// protocol-level failure.
|
||||
return validationFailedResult(cmdKey, seqErr.Error(), "Pass expected_seq as the whole-number seq you read."), nil
|
||||
}
|
||||
if hasSeq {
|
||||
payload["expected_seq"] = seq
|
||||
@@ -817,12 +819,22 @@ func (d *HTTPHandlerDispatcher) maybeInjectWorkspace(
|
||||
// A free function so it can be driven directly: the dispatcher method around it
|
||||
// needs a live handler chain.
|
||||
func itemExpectedSeqParam(input map[string]any) (int64, bool, error) {
|
||||
// BUG-4061: a float64 outside int64 is refused too (int64 of it is
|
||||
// implementation-defined), and a json.Number gets the same refusal rather
|
||||
// than reading as absent, which would make the write unguarded.
|
||||
switch v := input["expected_seq"].(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, false, fmt.Errorf("expected_seq must be a whole number, got %v", v)
|
||||
n, err := wholeInt64(v)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("expected_seq %w", err)
|
||||
}
|
||||
return int64(v), true, nil
|
||||
return n, true, nil
|
||||
case json.Number:
|
||||
n, err := jsonNumberInt64(v)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("expected_seq %w", err)
|
||||
}
|
||||
return n, true, nil
|
||||
case int64:
|
||||
return v, true, nil
|
||||
case int:
|
||||
|
||||
@@ -65,11 +65,14 @@ func (d *HTTPHandlerDispatcher) dispatchAttachmentList(
|
||||
if s, _ := input["sort"].(string); s != "" {
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if n, ok := numericInput(input["limit"]); ok && n > 0 {
|
||||
q.Set("limit", strconv.FormatInt(n, 10))
|
||||
}
|
||||
if n, ok := numericInput(input["offset"]); ok && n > 0 {
|
||||
q.Set("offset", strconv.FormatInt(n, 10))
|
||||
for _, key := range []string{"limit", "offset"} {
|
||||
n, ok, err := intParam(input, key)
|
||||
if err != nil {
|
||||
return validationFailedResult(cmdKey, err.Error(), "Pass "+key+" as a whole number."), nil
|
||||
}
|
||||
if ok && n > 0 {
|
||||
q.Set(key, strconv.FormatInt(n, 10))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case attached:
|
||||
|
||||
@@ -110,6 +110,13 @@ func chatSequence(input map[string]any, name string, min int64) (int64, error) {
|
||||
}
|
||||
n = int64(x)
|
||||
} else {
|
||||
// Every other type: the native integers and a plain-digit json.Number
|
||||
// parse here. A json.Number in exponent form ("1e6") and a float32
|
||||
// (fmt.Sprint(float32(1e6)) is "1e+06") are refused, not changed. Neither
|
||||
// reaches this today, since the JSON transports deliver only float64
|
||||
// (BUG-4061 note). A producer of either should be routed through the
|
||||
// same whole-number and range check numericInput uses; this path does
|
||||
// not do that today.
|
||||
n, err = strconv.ParseInt(fmt.Sprint(v), 10, 64)
|
||||
}
|
||||
if err != nil || n < min {
|
||||
|
||||
@@ -128,7 +128,11 @@ func (d *HTTPHandlerDispatcher) dispatchProjectStandup(
|
||||
return noWorkspaceResult(ctx, d.Lister), nil
|
||||
}
|
||||
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/standup"
|
||||
if q := buildQuery(input, map[string]string{"days": "days"}); q != "" {
|
||||
q, err := buildQuery(input, map[string]string{"days": "days"})
|
||||
if err != nil {
|
||||
return validationFailedResult(cmdKey, err.Error(), "Pass numeric parameters as whole numbers."), nil
|
||||
}
|
||||
if q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
|
||||
@@ -162,7 +166,11 @@ func (d *HTTPHandlerDispatcher) dispatchProjectChangelog(
|
||||
return noWorkspaceResult(ctx, d.Lister), nil
|
||||
}
|
||||
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/changelog"
|
||||
if q := buildQuery(input, map[string]string{"days": "days", "since": "since", "parent": "parent"}); q != "" {
|
||||
q, err := buildQuery(input, map[string]string{"days": "days", "since": "since", "parent": "parent"})
|
||||
if err != nil {
|
||||
return validationFailedResult(cmdKey, err.Error(), "Pass numeric parameters as whole numbers."), nil
|
||||
}
|
||||
if q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -67,7 +69,11 @@ func (s routeSpec) toRouteMapper() RouteMapper {
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if q := buildQuery(input, queryParams); q != "" {
|
||||
q, err := buildQuery(input, queryParams)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
var body []byte
|
||||
@@ -133,9 +139,15 @@ func expandPath(template string, input map[string]any) (string, error) {
|
||||
// Numbers from JSON arrive as float64; ints with no fractional part
|
||||
// emit without the .0. Booleans only emit when true (matching the
|
||||
// CLI's "presence-only" treatment).
|
||||
func buildQuery(input map[string]any, mapping map[string]string) string {
|
||||
//
|
||||
// BUG-4061: a number that is not a whole int64 is refused, naming the key.
|
||||
// Every numeric query parameter this builds is an integer (limit, offset,
|
||||
// days, budget), and the server's parsers read "1.5" as absent and use their
|
||||
// default (parseDaysParam, the limit parsers), so forwarding it silently
|
||||
// replaced the caller's number exactly as numericInput's truncation did.
|
||||
func buildQuery(input map[string]any, mapping map[string]string) (string, error) {
|
||||
if len(mapping) == 0 {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
q := url.Values{}
|
||||
for dst, src := range mapping {
|
||||
@@ -152,11 +164,12 @@ func buildQuery(input map[string]any, mapping map[string]string) string {
|
||||
if x {
|
||||
q.Set(dst, "true")
|
||||
}
|
||||
case float64:
|
||||
number, _ := formatNumber(x)
|
||||
q.Set(dst, number)
|
||||
case json.Number:
|
||||
q.Set(dst, x.String())
|
||||
case float64, json.Number:
|
||||
n, _, err := numericInput(x)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s %w", src, err)
|
||||
}
|
||||
q.Set(dst, strconv.FormatInt(n, 10))
|
||||
default:
|
||||
// BUG-4056 r1: float32 and the integer types share formatNumber.
|
||||
if number, ok := formatNumber(v); ok {
|
||||
@@ -167,9 +180,9 @@ func buildQuery(input map[string]any, mapping map[string]string) string {
|
||||
}
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
return q.Encode()
|
||||
return q.Encode(), nil
|
||||
}
|
||||
|
||||
// flatJSONBody serializes selected input keys into a JSON object.
|
||||
@@ -797,7 +810,11 @@ func mapItemStarred(input map[string]any) (string, string, []byte, error) {
|
||||
q.Set("include_terminal", "true")
|
||||
}
|
||||
// actionItemStarred always sets it, one past the MCP cap (BUG-3790).
|
||||
if n, ok := numericInput(input["limit"]); ok && int(n) > 0 {
|
||||
n, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if ok && int(n) > 0 {
|
||||
q.Set("limit", strconv.Itoa(int(n)))
|
||||
}
|
||||
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/starred"
|
||||
@@ -1194,11 +1211,19 @@ func mapWorkspaceAuditLog(input map[string]any) (string, string, []byte, error)
|
||||
if s, _ := input["actor"].(string); s != "" {
|
||||
q.Set("actor", s)
|
||||
}
|
||||
if n, ok := numericInput(input["days"]); ok && n > 0 {
|
||||
q.Set("days", strconv.FormatInt(n, 10))
|
||||
days, ok, err := intParam(input, "days")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if n, ok := numericInput(input["limit"]); ok && n > 0 {
|
||||
q.Set("limit", strconv.FormatInt(n, 10))
|
||||
if ok && days > 0 {
|
||||
q.Set("days", strconv.FormatInt(days, 10))
|
||||
}
|
||||
limit, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if ok && limit > 0 {
|
||||
q.Set("limit", strconv.FormatInt(limit, 10))
|
||||
}
|
||||
urlPath := "/api/v1/audit-log"
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
@@ -1409,11 +1434,19 @@ func mapItemList(input map[string]any) (string, string, []byte, error) {
|
||||
}
|
||||
|
||||
// Numeric filters.
|
||||
if n, ok := numericInput(input["limit"]); ok && n > 0 {
|
||||
values.Set("limit", strconv.FormatInt(n, 10))
|
||||
limit, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if n, ok := numericInput(input["offset"]); ok && n > 0 {
|
||||
values.Set("offset", strconv.FormatInt(n, 10))
|
||||
if ok && limit > 0 {
|
||||
values.Set("limit", strconv.FormatInt(limit, 10))
|
||||
}
|
||||
offset, ok, err := intParam(input, "offset")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if ok && offset > 0 {
|
||||
values.Set("offset", strconv.FormatInt(offset, 10))
|
||||
}
|
||||
|
||||
if b, _ := input["all"].(bool); b {
|
||||
@@ -1604,8 +1637,12 @@ func (d *HTTPHandlerDispatcher) dispatchItemHistory(
|
||||
|
||||
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/items/" + url.PathEscape(ref) + "/versions"
|
||||
q := url.Values{}
|
||||
if n, ok := numericInput(input["limit"]); ok && n > 0 {
|
||||
q.Set("limit", strconv.FormatInt(n, 10))
|
||||
limit, ok, err := intParam(input, "limit")
|
||||
if err != nil {
|
||||
return validationFailedResult(cmdKey, err.Error(), "Pass limit as a whole number."), nil
|
||||
}
|
||||
if ok && limit > 0 {
|
||||
q.Set("limit", strconv.FormatInt(limit, 10))
|
||||
}
|
||||
// Ask the server to skip reverse-patch resolution whenever the bodies are
|
||||
// about to be thrown away below. Without this the projection to
|
||||
@@ -1670,26 +1707,148 @@ func (d *HTTPHandlerDispatcher) dispatchItemHistory(
|
||||
|
||||
// numericInput pulls an int64 out of a JSON-typed input value. JSON
|
||||
// decoders deliver numbers as float64 (or json.Number when
|
||||
// UseNumber()); we accept both. Returns (0, false) for nil or
|
||||
// UseNumber()); we accept both. Returns (0, false, nil) for nil or
|
||||
// non-numeric inputs so callers can short-circuit.
|
||||
func numericInput(v any) (int64, bool) {
|
||||
//
|
||||
// BUG-4061: a number that is not a whole int64 is REFUSED, never changed.
|
||||
// int64(1.5) is 1 and int64(-2.7) is -2, and a float64 outside int64 converts
|
||||
// to an implementation-defined value, so the caller's number used to be
|
||||
// silently replaced by a different one. A json.Number that does not parse as
|
||||
// an int64 used to read as absent, so the parameter was silently dropped.
|
||||
// Both are now an error, and callers name the parameter (intParam).
|
||||
func numericInput(v any) (int64, bool, error) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return 0, false
|
||||
return 0, false, nil
|
||||
case float64:
|
||||
return int64(x), true
|
||||
case int:
|
||||
return int64(x), true
|
||||
case int64:
|
||||
return x, true
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
n, err := wholeInt64(x)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
return 0, false, err
|
||||
}
|
||||
return n, true
|
||||
return n, true, nil
|
||||
case int:
|
||||
return int64(x), true, nil
|
||||
case int64:
|
||||
return x, true, nil
|
||||
case json.Number:
|
||||
n, err := jsonNumberInt64(x)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
return 0, false
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// jsonNumberInt64 is wholeInt64 for a json.Number. Int64() alone refuses a
|
||||
// whole number written with an exponent ("1e6"), so a literal it cannot parse
|
||||
// is read EXACTLY as a decimal: a float64 would round 9007199254740993e0 to
|
||||
// ...992 and 1.0000000000000001 to 1, changing the caller's number again
|
||||
// (BUG-4061 r2). The length and exponent bounds keep a literal like 1e999999999
|
||||
// from costing a huge allocation; no int64 needs either to be larger.
|
||||
func jsonNumberInt64(x json.Number) (int64, error) {
|
||||
s := x.String()
|
||||
if n, err := x.Int64(); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
// An allow-list, not a deny-list (BUG-4061 r5): big.Rat parses base
|
||||
// prefixes ("0b101" is 5, "0o17" is 15, "0x10" is 16), fractions ("1/2")
|
||||
// and underscores, none of which is a JSON number.
|
||||
if len(s) > 64 || !isDecimalLiteral(s) {
|
||||
return 0, fmt.Errorf("must be a whole number in range, got %s", s)
|
||||
}
|
||||
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||
if e, err := strconv.Atoi(s[i+1:]); err != nil || e > 64 || e < -64 {
|
||||
return 0, fmt.Errorf("must be a whole number in range, got %s", s)
|
||||
}
|
||||
}
|
||||
r, ok := new(big.Rat).SetString(s)
|
||||
if !ok || !r.IsInt() {
|
||||
return 0, fmt.Errorf("must be a whole number, got %s", s)
|
||||
}
|
||||
if !r.Num().IsInt64() {
|
||||
return 0, fmt.Errorf("is out of range, got %s", s)
|
||||
}
|
||||
return r.Num().Int64(), nil
|
||||
}
|
||||
|
||||
// wholeInt64 converts a float64 that is exactly an int64, and refuses any
|
||||
// other: a fraction, or a value outside int64. 2^63 itself is out of range
|
||||
// (MaxInt64 is 2^63-1, and float64(MaxInt64) rounds up to 2^63). NaN fails
|
||||
// the Trunc comparison; ±Inf fails the range check.
|
||||
//
|
||||
// BUG-4061 r3/r4 (lead ruling on Q3, corrected on r4): a float64 of magnitude
|
||||
// 2^53 or more is refused too. The JSON decoder has already rounded such an
|
||||
// integer, and 2^53 itself is the rounded image of 2^53+1, so the value cannot
|
||||
// prove it is the number the caller sent. The largest accepted float64 is
|
||||
// 2^53-1 (the safe-integer bound). A json.Number is parsed exactly instead
|
||||
// (jsonNumberInt64) and keeps the int64 range.
|
||||
func wholeInt64(x float64) (int64, error) {
|
||||
if x != math.Trunc(x) {
|
||||
return 0, fmt.Errorf("must be a whole number, got %v", x)
|
||||
}
|
||||
if x >= 1<<63 || x < -(1<<63) {
|
||||
return 0, fmt.Errorf("is out of range, got %v", x)
|
||||
}
|
||||
if x >= 1<<53 || x <= -(1<<53) {
|
||||
return 0, fmt.Errorf("is beyond the exact integer range of a JSON number, got %v", x)
|
||||
}
|
||||
return int64(x), nil
|
||||
}
|
||||
|
||||
// isDecimalLiteral reports whether s is a plain decimal number: an optional
|
||||
// sign, digits with at most one '.', and at most one exponent ('e' or 'E',
|
||||
// an optional sign, then digits). At least one digit before the exponent.
|
||||
func isDecimalLiteral(s string) bool {
|
||||
i := 0
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
i++
|
||||
}
|
||||
digits, dot := 0, false
|
||||
for ; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
digits++
|
||||
case c == '.' && !dot:
|
||||
dot = true
|
||||
default:
|
||||
goto exponent
|
||||
}
|
||||
}
|
||||
exponent:
|
||||
if digits == 0 {
|
||||
return false
|
||||
}
|
||||
if i == len(s) {
|
||||
return true
|
||||
}
|
||||
if s[i] != 'e' && s[i] != 'E' {
|
||||
return false
|
||||
}
|
||||
i++
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
i++
|
||||
}
|
||||
if i == len(s) {
|
||||
return false
|
||||
}
|
||||
for ; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// intParam is numericInput on input[key], with the parameter named in the
|
||||
// error (BUG-4061).
|
||||
func intParam(input map[string]any, key string) (int64, bool, error) {
|
||||
n, ok, err := numericInput(input[key])
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("%s %w", key, err)
|
||||
}
|
||||
return n, ok, nil
|
||||
}
|
||||
|
||||
// mapItemMove dispatches `pad item move <ref> <target-collection>`.
|
||||
@@ -1759,7 +1918,7 @@ func mapItemSearch(input map[string]any) (string, string, []byte, error) {
|
||||
if query == "" {
|
||||
return "", "", nil, fmt.Errorf("query is required")
|
||||
}
|
||||
q := buildQuery(input, map[string]string{
|
||||
q, err := buildQuery(input, map[string]string{
|
||||
"q": "query",
|
||||
"workspace": "workspace",
|
||||
"collection": "collection",
|
||||
@@ -1769,6 +1928,9 @@ func mapItemSearch(input map[string]any) (string, string, []byte, error) {
|
||||
"limit": "limit",
|
||||
"offset": "offset",
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
urlPath := "/api/v1/search"
|
||||
if q != "" {
|
||||
urlPath += "?" + q
|
||||
|
||||
@@ -123,10 +123,13 @@ func TestExpandPath_UnclosedPlaceholderErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildQuery_SkipsEmptyAndMissing(t *testing.T) {
|
||||
got := buildQuery(
|
||||
got, qerr := buildQuery(
|
||||
map[string]any{"a": "x", "b": "", "c": nil, "d": "y"},
|
||||
map[string]string{"a": "a", "b": "b", "c": "c", "d": "d", "e": "e"},
|
||||
)
|
||||
if qerr != nil {
|
||||
t.Fatal(qerr)
|
||||
}
|
||||
values, err := parseQuery(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse query %q: %v", got, err)
|
||||
@@ -142,10 +145,13 @@ func TestBuildQuery_SkipsEmptyAndMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildQuery_RenamesAndTypes(t *testing.T) {
|
||||
got := buildQuery(
|
||||
got, qerr := buildQuery(
|
||||
map[string]any{"query": "OAuth", "limit": float64(50), "all": true, "skip": false},
|
||||
map[string]string{"q": "query", "limit": "limit", "include_archived": "all", "_skip": "skip"},
|
||||
)
|
||||
if qerr != nil {
|
||||
t.Fatal(qerr)
|
||||
}
|
||||
values, err := parseQuery(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse query: %v", err)
|
||||
@@ -173,8 +179,8 @@ func TestBuildQuery_HandlesJSONNumber(t *testing.T) {
|
||||
if err := dec.Decode(&input); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got := buildQuery(input, map[string]string{"limit": "limit"})
|
||||
if got != "limit=50" {
|
||||
got, qerr := buildQuery(input, map[string]string{"limit": "limit"})
|
||||
if qerr != nil || got != "limit=50" {
|
||||
t.Errorf("got %q, want limit=50", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
|
||||
|
||||
If the user is asking general code questions with no project-management thread, you don't need this server.
|
||||
|
||||
## Tool surface (v0.102)
|
||||
## Tool surface (v0.103)
|
||||
|
||||
Eleven resource × action tools, plus `pad_set_workspace`. Twelve tools total.
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ func formatNumber(v any) (string, bool) {
|
||||
case float64:
|
||||
x = n
|
||||
case float32:
|
||||
// UNREACHABLE today (BUG-4061 note): the JSON transports deliver only
|
||||
// float64, and nothing outside tests passes a float32.
|
||||
// Widening prints float32(0.1) as 0.10000000149011612 below; a float32
|
||||
// producer would want strconv.FormatFloat(float64(n), 'f', -1, 32).
|
||||
x = float64(n)
|
||||
case int:
|
||||
return strconv.Itoa(n), true
|
||||
|
||||
+18
-1
@@ -2225,7 +2225,24 @@ const CmdhelpVersion = "0.1"
|
||||
// mark failures preserve show/send success. Replies mark through reply_to. No direction.
|
||||
// Existing descriptions were reworded; examples/reasons moved to docs/mcp.md
|
||||
// without dropping any rule, caveat, refusal or ref.
|
||||
const ToolSurfaceVersion = "0.102"
|
||||
//
|
||||
// v0.103 — BUG-4061: a numeric parameter that is not a whole int64 is REFUSED
|
||||
// (validation_failed, naming the parameter) instead of silently changed. A
|
||||
// fraction used to truncate (limit 1.5 read as 1, limit 0.5 as the default), a
|
||||
// value outside int64 converted to an implementation-defined one, and a
|
||||
// json.Number that did not parse was dropped as absent. Covers limit, offset
|
||||
// and days on every remote door that reads them locally, pad_item.list and
|
||||
// pad_item.history's limit, every query-string door built by buildQuery (item
|
||||
// backlinks, item context's budget, project activity, item search, project
|
||||
// standup and changelog), whose server parsers used to default a forwarded
|
||||
// "1.5" silently, and expected_seq (range and json.Number; fractions were
|
||||
// already refused). A whole json.Number in exponent form ("1e6") is accepted,
|
||||
// and is parsed exactly. A JSON number decoded as float64 of magnitude 2^53
|
||||
// or more is refused: the decoder may already have rounded it.
|
||||
// The schemas declare these as "number", so the refused inputs were valid
|
||||
// under the advertised shape: the bump is owed by the behaviour, not by a
|
||||
// schema change (the precedent is the multi_relation DSL refusal).
|
||||
const ToolSurfaceVersion = "0.103"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
Reference in New Issue
Block a user