This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new b205d5f4f fix(rmqctl): three CLI defects in nested cells, 
confirm-token replay and stdio error frames (#5643)
b205d5f4f is described below

commit b205d5f4fa9b78cf912fc389074129c3d7d394b5
Author: Apulupie <[email protected]>
AuthorDate: Sat Oct 10 14:38:31 2026 +0800

    fix(rmqctl): three CLI defects in nested cells, confirm-token replay and 
stdio error frames (#5643)
    
    Three rmqctl defects.
    
    - Nested objects and arrays in table cells were rendered with Go's 
`map[k:v]` syntax instead of the JSON
      the server sent; `stringify` is the single funnel, so one change covers 
every column. (#5643)
    - A confirm token could be replayed with an auto-filled timestamp: the 
signing side does not include
      `timestamp` in `CONTROL_FIELDS` while the filling side refreshes it per 
call, so the guard now
      rejects that combination. (#5654)
    - The stdio proxy's fallback error frame dropped the underlying cause; it 
now reuses the existing
      `normalizeCLIError` / `safeStdioDiagnostic` path. (#5656)
    
    `go test ./...` green across all seven rmqctl packages, `make fmt` clean. 
Rebased onto today's
    #6057 (`fix(rmqctl): restore numeric types before YAML encoding`), which 
the yaml golden test needs.
    
    Folded in #5654 and #5656 (same author).
---
 rmqctl/cmd/catalog.go                 |  33 ++++++-
 rmqctl/cmd/catalog_instance_test.go   |  68 ++++++++++++++
 rmqctl/cmd/mcp_stdio.go               |  23 +++++
 rmqctl/cmd/mcp_stdio_error_test.go    | 166 ++++++++++++++++++++++++++++++++++
 rmqctl/internal/output/output.go      |   9 ++
 rmqctl/internal/output/output_test.go |  31 +++++++
 6 files changed, 327 insertions(+), 3 deletions(-)

diff --git a/rmqctl/cmd/catalog.go b/rmqctl/cmd/catalog.go
index 0f230cb51..7f1dddb11 100644
--- a/rmqctl/cmd/catalog.go
+++ b/rmqctl/cmd/catalog.go
@@ -192,7 +192,7 @@ func runTool(
        // Fill client-side defaults (x-client-default: NOW) before required
        // validation and argument assembly so a schema-required field with a
        // client default is self-consistent. Explicit flags always win.
-       applyClientDefaults(tool, arguments)
+       filled := applyClientDefaults(tool, arguments)
        // Pass the explicit --instance-id value through to tools whose schema
        // declares the instance identifier (decision 7: pass-through of the
        // caller's explicit value, not a default injection). Platform-level
@@ -203,6 +203,9 @@ func runTool(
        if err := validateSchemaArguments(tool, tool.InputSchema, arguments); 
err != nil {
                return err
        }
+       if err := rejectReplayWithAutoFilledDefaults(arguments, filled); err != 
nil {
+               return err
+       }
 
        if err := confirmRisk(cmd, runtime, tool, arguments); err != nil {
                return err
@@ -264,8 +267,10 @@ var nowMillis = func() int64 { return 
time.Now().UnixMilli() }
 // Filling happens before required validation so required fields with a client
 // default (e.g. group reset-offset --timestamp) validate cleanly. The catalog
 // generator rejects x-client-default on nested properties, so only top-level
-// fields need to be considered.
-func applyClientDefaults(tool toolcatalog.Tool, arguments map[string]any) {
+// fields need to be considered. The filled fields are returned so a caller can
+// tell an auto-filled value from an explicitly supplied one.
+func applyClientDefaults(tool toolcatalog.Tool, arguments map[string]any) 
[]toolcatalog.Field {
+       var filled []toolcatalog.Field
        for _, field := range tool.InputSchema.Fields {
                if field.ClientDefault != toolcatalog.ClientDefaultNow {
                        continue
@@ -274,7 +279,29 @@ func applyClientDefaults(tool toolcatalog.Tool, arguments 
map[string]any) {
                        continue
                }
                arguments[field.Name] = nowMillis()
+               filled = append(filled, field)
+       }
+       return filled
+}
+
+// rejectReplayWithAutoFilledDefaults refuses a --confirm-token replay whose
+// previewed input cannot be reproduced. The server signs the previewed
+// business input into the token (the timestamp of a group reset-offset
+// preview included), while an x-client-default: NOW field is filled on every
+// invocation — so a replay that omits the flag carries a fresh value, never
+// matches the preview, and is answered with a mismatch error whose hint
+// ("without changing the operation input") the caller did obey. Naming the
+// flag to pin turns that dead end into an instruction.
+func rejectReplayWithAutoFilledDefaults(arguments map[string]any, filled 
[]toolcatalog.Field) error {
+       if len(filled) == 0 || !argumentPresent(arguments, "confirm_token") {
+               return nil
        }
+       field := filled[0]
+       return types.NewCLIError(types.CodeInvalidArgument,
+               fmt.Sprintf("--confirm-token cannot replay an auto-filled 
--%s", field.Flag),
+               fmt.Sprintf("The preview signed its own --%s into the token, 
and this call filled a new value, so the server "+
+                       "would reject it as a preview mismatch. Pin the 
previewed value with --%s <value from the preview plan>, or "+
+                       "drop --confirm-token to let --yes run the preview and 
the call together.", field.Flag, field.Flag))
 }
 
 func schemaFlagUsage(field toolcatalog.Field) string {
diff --git a/rmqctl/cmd/catalog_instance_test.go 
b/rmqctl/cmd/catalog_instance_test.go
index 8fbc3a6ca..e8fdbb59a 100644
--- a/rmqctl/cmd/catalog_instance_test.go
+++ b/rmqctl/cmd/catalog_instance_test.go
@@ -19,8 +19,10 @@ package cmd
 import (
        "bytes"
        "encoding/json"
+       "errors"
        "net/http"
        "net/http/httptest"
+       "strings"
        "testing"
        "time"
 
@@ -209,3 +211,69 @@ func TestClientDefaultNowSatisfiesRequiredValidation(t 
*testing.T) {
                t.Fatalf("arguments = %#v, want the filled timestamp", 
request.Arguments)
        }
 }
+
+func syntheticConfirmedResetTool() toolcatalog.Tool {
+       return toolcatalog.Tool{
+               Name:                 "rmq.synthetic.confirmed-reset",
+               CLI:                  toolcatalog.CLI{Resource: 
"synthetic-confirmed-reset", Verb: "run"},
+               Description:          "Synthetic L2 tool combining an 
x-client-default NOW field with the two-phase handshake.",
+               RiskLevel:            "L2",
+               Permission:           "synthetic:write",
+               RequiredCapabilities: []string{},
+               InputSchema: toolcatalog.InputSchema{
+                       Fields: []toolcatalog.Field{
+                               {Name: "instanceId", Flag: "instance-id", Kind: 
toolcatalog.StringField, Required: true, MinLength: 1},
+                               {Name: "groupName", Flag: "group-name", Kind: 
toolcatalog.StringField, Required: true, MinLength: 1},
+                               {Name: "timestamp", Flag: "timestamp", Kind: 
toolcatalog.IntegerField, Required: true, ClientDefault: 
toolcatalog.ClientDefaultNow},
+                               {Name: "dry_run", Flag: "dry-run", Kind: 
toolcatalog.BooleanField},
+                               {Name: "confirm_token", Flag: "confirm-token", 
Kind: toolcatalog.StringField, MinLength: 1},
+                       },
+               },
+               ViewHint: "object",
+       }
+}
+
+// TestConfirmTokenReplayWithAutoFilledTimestampIsRefused verifies the
+// two-phase handshake against the server's signing rules: the token signs the
+// previewed business input, including the timestamp of a group reset-offset
+// preview, while an x-client-default: NOW field is filled on every invocation.
+// A replay that carries a --confirm-token but omits the flag therefore cannot
+// ever match its preview, so the CLI refuses it up front and names the flag to
+// pin instead of sending a call the server is guaranteed to answer with a
+// preview-mismatch error.
+func TestConfirmTokenReplayWithAutoFilledTimestampIsRefused(t *testing.T) {
+       const pinnedNow = int64(1757600000000)
+       original := nowMillis
+       nowMillis = func() int64 { return pinnedNow }
+       defer func() { nowMillis = original }()
+
+       request, stderr, err := executeSyntheticTool(t, 
syntheticConfirmedResetTool(),
+               "--group-name", "gid-orders", "--confirm-token", 
"server-issued", "--yes")
+       if err == nil {
+               t.Fatalf("a confirm-token replay with an auto-filled timestamp 
must be refused, request=%#v", request)
+       }
+       var cliError *types.CLIError
+       if !errors.As(err, &cliError) || cliError.Code != 
types.CodeInvalidArgument {
+               t.Fatalf("err = %v, want INVALID_ARGUMENT", err)
+       }
+       if !strings.Contains(cliError.Hint, "--timestamp") {
+               t.Fatalf("hint = %q, want it to name the flag to pin", 
cliError.Hint)
+       }
+       if request.Name != "" {
+               t.Fatalf("refused replay must not reach the server, observed 
%q, stderr=%s", request.Name, stderr)
+       }
+}
+
+// TestExplicitTimestampStillReplaysConfirmToken verifies the refusal is
+// specific to the auto-filled value: pinning the previewed timestamp keeps the
+// token usable, which is the path the hint points at.
+func TestExplicitTimestampStillReplaysConfirmToken(t *testing.T) {
+       request, stderr, err := executeSyntheticTool(t, 
syntheticConfirmedResetTool(),
+               "--group-name", "gid-orders", "--timestamp", "1791394886405", 
"--confirm-token", "server-issued", "--yes")
+       if err != nil {
+               t.Fatalf("an explicit timestamp must keep the token usable: %v, 
stderr=%s", err, stderr)
+       }
+       if request.Arguments["confirm_token"] != "server-issued" {
+               t.Fatalf("arguments = %#v, want the confirm token to be sent", 
request.Arguments)
+       }
+}
diff --git a/rmqctl/cmd/mcp_stdio.go b/rmqctl/cmd/mcp_stdio.go
index f5f96d5b4..087197e36 100644
--- a/rmqctl/cmd/mcp_stdio.go
+++ b/rmqctl/cmd/mcp_stdio.go
@@ -306,6 +306,29 @@ func writeStdioError(out io.Writer, errOut io.Writer, 
requestLine string, callEr
                                Hint:    hint,
                        },
                }
+       } else {
+               // Connection refusals, TLS failures, timeouts and 
protocol-decode errors carry no HTTP
+               // status, but the caller still needs the cause: an MCP client 
that only receives
+               // "stdio proxy call failed" cannot tell the user (or the 
model) anything but "go look at
+               // the logs". normalizeCLIError classifies them the same way 
the CLI's own error path
+               // does, and the sanitized text travels in both message and 
data so the frame is
+               // actionable on its own.
+               cliError := normalizeCLIError(callErr)
+               code := safeStdioDiagnostic(cliError.Code)
+               message := safeStdioDiagnostic(cliError.Message)
+               hint := safeStdioDiagnostic(cliError.Hint)
+               if message == "" {
+                       message = "stdio proxy call failed"
+               }
+               rpcError = jsonRPCError{
+                       Code:    jsonrpcServerErrorCode,
+                       Message: message,
+                       Data: &jsonRPCErrorData{
+                               Code:    code,
+                               Message: message,
+                               Hint:    hint,
+                       },
+               }
        }
        errorPayload := jsonRPCErrorPayload{
                JSONRPC: "2.0",
diff --git a/rmqctl/cmd/mcp_stdio_error_test.go 
b/rmqctl/cmd/mcp_stdio_error_test.go
new file mode 100644
index 000000000..c8ac48bef
--- /dev/null
+++ b/rmqctl/cmd/mcp_stdio_error_test.go
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cmd
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net"
+       "strings"
+       "testing"
+
+       "github.com/apache/rocketmq-dashboard/rmqctl/internal/studio"
+)
+
+func decodeStdioErrorFrame(t *testing.T, out *bytes.Buffer) 
jsonRPCErrorPayload {
+       t.Helper()
+       frameLine := strings.TrimSpace(out.String())
+       if frameLine == "" {
+               t.Fatal("no error frame was written to stdout")
+       }
+       if strings.Contains(frameLine, "\n") {
+               t.Fatalf("stdout carries more than one line: %q", frameLine)
+       }
+       var payload jsonRPCErrorPayload
+       if err := json.Unmarshal([]byte(frameLine), &payload); err != nil {
+               t.Fatalf("stdout frame is not JSON: %v (%q)", err, frameLine)
+       }
+       return payload
+}
+
+// TestStdioErrorFrameKeepsTheFailureCause verifies that a call failure without
+// an HTTP status still tells the MCP client what went wrong: the classified
+// code, the sanitized cause and a hint travel in the frame's data, instead of
+// collapsing into a bare "stdio proxy call failed".
+func TestStdioErrorFrameKeepsTheFailureCause(t *testing.T) {
+       connectionRefused := &net.OpError{Op: "dial", Net: "tcp", Err: 
errors.New("connect: connection refused")}
+       cases := []struct {
+               name        string
+               callErr     error
+               wantCode    string
+               wantMessage string
+       }{
+               {
+                       name:        "transport failure",
+                       callErr:     fmt.Errorf("failed to send request: %w", 
connectionRefused),
+                       wantCode:    "UNAVAILABLE",
+                       wantMessage: "connection refused",
+               },
+               {
+                       name:        "deadline exceeded",
+                       callErr:     fmt.Errorf("mcp call: %w", 
context.DeadlineExceeded),
+                       wantCode:    "TIMEOUT",
+                       wantMessage: "deadline exceeded",
+               },
+               {
+                       name:        "protocol decode failure",
+                       callErr:     errors.New("decode MCP JSON-RPC response: 
unexpected end of JSON input"),
+                       wantCode:    "COMMAND_FAILED",
+                       wantMessage: "unexpected end of JSON input",
+               },
+       }
+       for _, testCase := range cases {
+               t.Run(testCase.name, func(t *testing.T) {
+                       out := &bytes.Buffer{}
+                       errOut := &bytes.Buffer{}
+                       writeStdioError(out, errOut, 
`{"jsonrpc":"2.0","id":7,"method":"tools/call"}`, testCase.callErr)
+
+                       frame := decodeStdioErrorFrame(t, out)
+                       if frame.Error.Code != jsonrpcServerErrorCode {
+                               t.Fatalf("frame code = %d, want %d", 
frame.Error.Code, jsonrpcServerErrorCode)
+                       }
+                       if frame.Error.Data == nil {
+                               t.Fatal("error frame carries no data payload")
+                       }
+                       if frame.Error.Data.Code != testCase.wantCode {
+                               t.Fatalf("data.code = %q, want %q", 
frame.Error.Data.Code, testCase.wantCode)
+                       }
+                       if !strings.Contains(frame.Error.Message, 
testCase.wantMessage) {
+                               t.Fatalf("message = %q, want it to contain %q", 
frame.Error.Message, testCase.wantMessage)
+                       }
+                       if !strings.Contains(frame.Error.Data.Message, 
testCase.wantMessage) {
+                               t.Fatalf("data.message = %q, want it to contain 
%q", frame.Error.Data.Message, testCase.wantMessage)
+                       }
+                       if frame.Error.Data.Hint == "" {
+                               t.Fatal("data.hint is empty, so the caller has 
nothing to act on")
+                       }
+                       if !strings.Contains(errOut.String(), 
testCase.wantMessage) {
+                               t.Fatalf("stderr = %q, want the cause to stay 
there too", errOut.String())
+                       }
+               })
+       }
+}
+
+// TestStdioErrorFrameKeepsTheHTTPStatusShape pins the branch that already
+// worked: a structured MCP HTTP error keeps its own code, message and hint.
+func TestStdioErrorFrameKeepsTheHTTPStatusShape(t *testing.T) {
+       out := &bytes.Buffer{}
+       errOut := &bytes.Buffer{}
+       writeStdioError(out, errOut, 
`{"jsonrpc":"2.0","id":"srv-1","method":"tools/call"}`,
+               &studio.MCPHTTPStatusError{StatusCode: 403, Code: "FORBIDDEN", 
Message: "instance mismatch", Hint: "check the instance id"})
+
+       frame := decodeStdioErrorFrame(t, out)
+       if frame.Error.Code != jsonrpcServerErrorCode {
+               t.Fatalf("frame code = %d, want %d", frame.Error.Code, 
jsonrpcServerErrorCode)
+       }
+       if frame.Error.Data == nil || frame.Error.Data.Code != "FORBIDDEN" {
+               t.Fatalf("data = %#v, want the server's FORBIDDEN code", 
frame.Error.Data)
+       }
+       if frame.Error.Message != "instance mismatch" || frame.Error.Data.Hint 
!= "check the instance id" {
+               t.Fatalf("frame = %#v, want the server's message and hint", 
frame.Error)
+       }
+}
+
+// TestStdioErrorFrameSanitizesAndCapsTheCause verifies the frame stays a
+// single line and within the diagnostic cap even for a multi-line, oversized
+// failure text.
+func TestStdioErrorFrameSanitizesAndCapsTheCause(t *testing.T) {
+       out := &bytes.Buffer{}
+       errOut := &bytes.Buffer{}
+       writeStdioError(out, errOut, 
`{"jsonrpc":"2.0","id":1,"method":"tools/call"}`,
+               fmt.Errorf("first line\r\nsecond\tline %s", strings.Repeat("x", 
4*stdioDiagnosticLimit)))
+
+       frame := decodeStdioErrorFrame(t, out)
+       if len(frame.Error.Message) > stdioDiagnosticLimit {
+               t.Fatalf("message length = %d, want at most %d", 
len(frame.Error.Message), stdioDiagnosticLimit)
+       }
+       if strings.ContainsAny(frame.Error.Message, "\r\n\t") {
+               t.Fatalf("message = %q, want control characters flattened", 
frame.Error.Message)
+       }
+       if !strings.Contains(frame.Error.Message, "first line") || 
!strings.Contains(frame.Error.Message, "second line") {
+               t.Fatalf("message = %q, want the sanitized cause", 
frame.Error.Message)
+       }
+}
+
+// TestStdioErrorFrameSkipsUnparsableRequests pins the deliberate part of the
+// current behaviour: without a request id there is nothing a JSON-RPC error
+// frame could address, so only stderr carries the diagnostic.
+func TestStdioErrorFrameSkipsUnparsableRequests(t *testing.T) {
+       out := &bytes.Buffer{}
+       errOut := &bytes.Buffer{}
+       writeStdioError(out, errOut, "not json", errors.New("connect: 
connection refused"))
+
+       if out.Len() != 0 {
+               t.Fatalf("stdout = %q, want no frame for an unparsable 
request", out.String())
+       }
+       if !strings.Contains(errOut.String(), "connection refused") {
+               t.Fatalf("stderr = %q, want the diagnostic", errOut.String())
+       }
+}
diff --git a/rmqctl/internal/output/output.go b/rmqctl/internal/output/output.go
index 90ec62bb6..4860c933c 100644
--- a/rmqctl/internal/output/output.go
+++ b/rmqctl/internal/output/output.go
@@ -213,5 +213,14 @@ func stringify(value any) string {
        if f, ok := value.(float64); ok {
                return strconv.FormatFloat(f, 'f', -1, 64)
        }
+       // Nested objects and arrays decoded from the tool output are JSON to 
begin with;
+       // render them as JSON instead of Go's default map/slice syntax 
("map[k:v]"),
+       // which is noisy and not what the server sent.
+       switch value.(type) {
+       case map[string]any, []any:
+               if encoded, err := json.Marshal(value); err == nil {
+                       return string(encoded)
+               }
+       }
        return fmt.Sprint(value)
 }
diff --git a/rmqctl/internal/output/output_test.go 
b/rmqctl/internal/output/output_test.go
index e6f9b5e13..28e517647 100644
--- a/rmqctl/internal/output/output_test.go
+++ b/rmqctl/internal/output/output_test.go
@@ -97,3 +97,34 @@ func TestRowsEscapesControlCharactersInCellValues(t 
*testing.T) {
                t.Fatalf("data line does not contain escaped value: %q", data)
        }
 }
+
+func TestRowsRendersNestedValuesAsJSON(t *testing.T) {
+       buf := &bytes.Buffer{}
+       rows := []map[string]any{
+               {
+                       "name":  "group-a",
+                       "quota": map[string]any{"max": float64(10), "used": 
float64(3)},
+                       "tags":  []any{"a", "b"},
+               },
+       }
+       columns := []Column{
+               {Header: "NAME", Key: "name"},
+               {Header: "QUOTA", Key: "quota"},
+               {Header: "TAGS", Key: "tags"},
+       }
+       if err := Rows(buf, rows, columns); err != nil {
+               t.Fatal(err)
+       }
+       data := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")[1]
+       // Go's default map/slice formatting is noise ("map[max:10 used:3]"), 
and nested
+       // values are JSON to begin with, so the cell should carry the JSON 
form instead.
+       if strings.Contains(data, "map[") || strings.Contains(data, "[a b]") {
+               t.Fatalf("data line renders Go syntax for nested values: %q", 
data)
+       }
+       if !strings.Contains(data, `"max":10`) {
+               t.Fatalf("data line does not render the nested object as JSON: 
%q", data)
+       }
+       if !strings.Contains(data, `["a","b"]`) {
+               t.Fatalf("data line does not render the nested array as JSON: 
%q", data)
+       }
+}

Reply via email to