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)
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
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"padToolSurface": {
|
||||
"tool_surface_stable": true,
|
||||
"version": "0.102"
|
||||
"version": "0.103"
|
||||
}
|
||||
},
|
||||
"prompts": {},
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"padToolSurface": {
|
||||
"tool_surface_stable": true,
|
||||
"version": "0.102"
|
||||
"version": "0.103"
|
||||
}
|
||||
},
|
||||
"prompts": {},
|
||||
|
||||
+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") {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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"},
|
||||
{name: "-2^63 is in range", in: -float64(1 << 63), want: -1 << 63, 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: "got 1e19"},
|
||||
{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
|
||||
}{
|
||||
{[]string{"attachment", "list"}, map[string]any{"limit": 1.5}, "limit"},
|
||||
{[]string{"attachment", "list"}, map[string]any{"offset": 2.5}, "offset"},
|
||||
{[]string{"item", "history"}, map[string]any{"ref": "TASK-1", "limit": 1.5}, "limit"},
|
||||
{[]string{"item", "starred"}, map[string]any{"limit": 1.5}, "limit"},
|
||||
{[]string{"workspace", "audit-log"}, map[string]any{"days": 1.5}, "days"},
|
||||
{[]string{"workspace", "audit-log"}, map[string]any{"limit": 1e19}, "limit"},
|
||||
{[]string{"item", "list"}, map[string]any{"limit": -2.7}, "limit"},
|
||||
{[]string{"item", "list"}, map[string]any{"offset": 1.5}, "offset"},
|
||||
}
|
||||
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) {
|
||||
reached = 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)
|
||||
}
|
||||
if txt := textOf(res); !strings.Contains(txt, tc.param+" ") || !strings.Contains(txt, string(ErrValidationFailed)) {
|
||||
t.Fatalf("refusal does not name %q as a validation failure: %s", tc.param, 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},
|
||||
} {
|
||||
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"},
|
||||
{"json.Number whole", json.Number("42"), 42, ""},
|
||||
{"json.Number fraction", json.Number("42.7"), 0, "expected_seq must be a whole number in range, got 42.7"},
|
||||
{"json.Number above int64", json.Number("1e19"), 0, "expected_seq must be a whole number in 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"
|
||||
@@ -817,12 +816,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 := v.Int64()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("expected_seq must be a whole number in range, got %s", v.String())
|
||||
}
|
||||
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,11 @@ func chatSequence(input map[string]any, name string, min int64) (int64, error) {
|
||||
}
|
||||
n = int64(x)
|
||||
} else {
|
||||
// Every other type: json.Number and the native integers parse here.
|
||||
// A float32 would not (fmt.Sprint(float32(1e6)) is "1e+06"), but none
|
||||
// reaches this today, since the JSON transports deliver only float64
|
||||
// (BUG-4061 note). A float32 producer would route through the same
|
||||
// integral and range check as float64 above.
|
||||
n, err = strconv.ParseInt(fmt.Sprint(v), 10, 64)
|
||||
}
|
||||
if err != nil || n < min {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -797,7 +798,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 +1199,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 +1422,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 +1625,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 +1695,61 @@ 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
|
||||
n, err := wholeInt64(x)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return n, true, nil
|
||||
case int:
|
||||
return int64(x), true
|
||||
return int64(x), true, nil
|
||||
case int64:
|
||||
return x, true
|
||||
return x, true, nil
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
return 0, false, fmt.Errorf("must be a whole number in range, got %s", x.String())
|
||||
}
|
||||
return n, true
|
||||
return n, true, nil
|
||||
}
|
||||
return 0, false
|
||||
return 0, false, 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.
|
||||
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)
|
||||
}
|
||||
return int64(x), nil
|
||||
}
|
||||
|
||||
// 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>`.
|
||||
|
||||
@@ -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 in internal/mcp or cmd/pad 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
|
||||
|
||||
+12
-1
@@ -2225,7 +2225,18 @@ 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, pad_item.list and pad_item.history's limit,
|
||||
// and expected_seq (range and json.Number; fractions were already refused).
|
||||
// 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