From 9a7f69e072ae2f627c84cdbc0518793dc11b6142 Mon Sep 17 00:00:00 2001 From: pionxe Date: Wed, 15 Apr 2026 11:31:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(gateway):=20[EPIC-GW-03]=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=8E=A7=E5=88=B6=E9=9D=A2=E5=A5=91=E7=BA=A6=E7=A1=AC?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=20JSON-RPC=20=E5=8D=8F=E8=AE=AE=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现控制面底层 IPC 传输从“裸 MessageFrame”向“标准 JSON-RPC 2.0”的直接无缝切换,硬化内部契约,为后续 Runtime 接入奠定稳定基座。 主要改动: 1. 协议归一化:新增 JSON-RPC 解析层,使用 json.RawMessage 延迟解析 params,并统一收敛至内部 MessageFrame 进行路由。 2. 错误模型重构:采用标准 JSON-RPC 错误码 (error.code),并将网关业务错误码统一映射至 error.data.gateway_code。 3. 适配器升级:url-dispatch 全面切换为 JSON-RPC 收发,并补齐了严密的 RequestID/Action 响应关联校验。 4. 稳定性增强:修复 URL 调度器的 context 取消中断逻辑(防止无界阻塞),并修复 Windows 环境下的绝对路径越界检测漏洞。 5. 测试覆盖:大幅增补 Server/Dispatcher 协议回归单测,网关核心覆盖率达 95.3%。 --- .../gateway/adapters/urlscheme/dispatcher.go | 109 ++++++- .../adapters/urlscheme/dispatcher_test.go | 287 +++++++++++++++--- internal/gateway/handlers/wake.go | 2 +- internal/gateway/protocol/jsonrpc.go | 274 +++++++++++++++++ internal/gateway/protocol/jsonrpc_test.go | 174 +++++++++++ internal/gateway/server.go | 89 ++++-- internal/gateway/server_additional_test.go | 88 +++++- internal/gateway/server_test.go | 92 +++--- 8 files changed, 1004 insertions(+), 111 deletions(-) create mode 100644 internal/gateway/protocol/jsonrpc.go create mode 100644 internal/gateway/protocol/jsonrpc_test.go diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 2abf68a3..74ab7ccd 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -1,11 +1,13 @@ package urlscheme import ( + "bytes" "context" "encoding/json" "errors" "fmt" "net" + "strings" "sync/atomic" "time" @@ -110,28 +112,69 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis Payload: intent, } + requestIDRaw, err := marshalJSONRawMessage(requestFrame.RequestID) + if err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("encode request id: %v", err)) + } + requestParamsRaw, err := marshalJSONRawMessage(intent) + if err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("encode request params: %v", err)) + } + rpcRequest := protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: requestIDRaw, + Method: protocol.MethodWakeOpenURL, + Params: requestParamsRaw, + } + if err := ensureDispatchContextActive(ctx); err != nil { return DispatchResult{}, toDispatchError(err) } encoder := json.NewEncoder(conn) - if err := encoder.Encode(requestFrame); err != nil { + if err := encoder.Encode(rpcRequest); err != nil { if ctx != nil && ctx.Err() != nil { ctxErr := ctx.Err() return DispatchResult{}, toDispatchError(ctxErr) } - return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("write request frame: %v", err)) + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("write request rpc: %v", err)) } - var responseFrame gateway.MessageFrame + var rpcResponse protocol.JSONRPCResponse if err := ensureDispatchContextActive(ctx); err != nil { return DispatchResult{}, toDispatchError(err) } decoder := json.NewDecoder(conn) - if err := decoder.Decode(&responseFrame); err != nil { + if err := decoder.Decode(&rpcResponse); err != nil { if ctx != nil && ctx.Err() != nil { ctxErr := ctx.Err() return DispatchResult{}, toDispatchError(ctxErr) } + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response rpc: %v", err)) + } + if strings.TrimSpace(rpcResponse.JSONRPC) != protocol.JSONRPCVersion { + return DispatchResult{}, newDispatchError( + ErrorCodeUnexpectedResponse, + "unexpected response jsonrpc version", + ) + } + if !rawJSONMessageEqual(rpcResponse.ID, rpcRequest.ID) { + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, "rpc correlation failed: id mismatch") + } + if rpcResponse.Error != nil && rpcResponse.Result != nil { + return DispatchResult{}, newDispatchError( + ErrorCodeUnexpectedResponse, + "unexpected response payload: both result and error are present", + ) + } + if rpcResponse.Error != nil { + return DispatchResult{}, toDispatchErrorFromJSONRPC(rpcResponse.Error) + } + if rpcResponse.Result == nil { + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, "gateway response missing result payload") + } + + responseFrame, err := decodeResponseFrameResult(rpcResponse.Result) + if err != nil { return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) } if responseFrame.Action != requestFrame.Action || responseFrame.RequestID != requestFrame.RequestID { @@ -201,6 +244,64 @@ func watchDispatchCancellation(ctx context.Context, conn net.Conn) func() { } } +// toDispatchErrorFromJSONRPC 将 JSON-RPC 错误对象映射为 url-dispatch 稳定错误。 +func toDispatchErrorFromJSONRPC(rpcError *protocol.JSONRPCError) error { + if rpcError == nil { + return newDispatchError(ErrorCodeUnexpectedResponse, "gateway returned empty rpc error payload") + } + + code := strings.TrimSpace(protocol.GatewayCodeFromJSONRPCError(rpcError)) + if code == "" { + code = mapJSONRPCCodeToDispatchCode(rpcError.Code) + } + message := strings.TrimSpace(rpcError.Message) + if message == "" { + message = "gateway returned empty rpc error message" + } + return newDispatchError(code, message) +} + +// mapJSONRPCCodeToDispatchCode 为缺少 gateway_code 的响应提供兜底错误码映射。 +func mapJSONRPCCodeToDispatchCode(code int) string { + switch code { + case protocol.JSONRPCCodeMethodNotFound: + return gateway.ErrorCodeUnsupportedAction.String() + case protocol.JSONRPCCodeInvalidRequest, protocol.JSONRPCCodeInvalidParams, protocol.JSONRPCCodeParseError: + return gateway.ErrorCodeInvalidFrame.String() + case protocol.JSONRPCCodeInternalError: + return gateway.ErrorCodeInternalError.String() + default: + return ErrorCodeInternal + } +} + +// decodeResponseFrameResult 将 JSON-RPC result 安全解码回 MessageFrame。 +func decodeResponseFrameResult(result any) (gateway.MessageFrame, error) { + raw, err := json.Marshal(result) + if err != nil { + return gateway.MessageFrame{}, err + } + var frame gateway.MessageFrame + if err := json.Unmarshal(raw, &frame); err != nil { + return gateway.MessageFrame{}, err + } + return frame, nil +} + +// rawJSONMessageEqual 比较两段 JSON 原文在去除首尾空白后的字节是否一致。 +func rawJSONMessageEqual(left, right json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(left), bytes.TrimSpace(right)) +} + +// marshalJSONRawMessage 将任意对象编码为 json.RawMessage,便于构造 JSON-RPC 请求字段。 +func marshalJSONRawMessage(payload any) (json.RawMessage, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return json.RawMessage(raw), nil +} + // toDispatchError 将不同来源错误转换为统一结构化错误。 func toDispatchError(err error) error { if err == nil { diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index e371a4e2..e52569c6 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -12,6 +12,7 @@ import ( "time" "neo-code/internal/gateway" + "neo-code/internal/gateway/protocol" "neo-code/internal/gateway/transport" ) @@ -40,25 +41,29 @@ func TestDispatcherDispatchSuccess(t *testing.T) { decoder := json.NewDecoder(serverConn) encoder := json.NewEncoder(serverConn) - var requestFrame gateway.MessageFrame - if err := decoder.Decode(&requestFrame); err != nil { - t.Errorf("decode request frame: %v", err) + var rpcRequest protocol.JSONRPCRequest + if err := decoder.Decode(&rpcRequest); err != nil { + t.Errorf("decode request rpc: %v", err) return } - if requestFrame.Action != gateway.FrameActionWakeOpenURL { - t.Errorf("request action = %q, want %q", requestFrame.Action, gateway.FrameActionWakeOpenURL) + if rpcRequest.Method != protocol.MethodWakeOpenURL { + t.Errorf("request method = %q, want %q", rpcRequest.Method, protocol.MethodWakeOpenURL) return } - if err := encoder.Encode(gateway.MessageFrame{ - Type: gateway.FrameTypeAck, - Action: gateway.FrameActionWakeOpenURL, - RequestID: requestFrame.RequestID, - Payload: map[string]any{ - "message": "wake intent accepted", + if err := encoder.Encode(protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: rpcRequest.ID, + Result: gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-1", + Payload: map[string]any{ + "message": "wake intent accepted", + }, }, }); err != nil { - t.Errorf("encode response frame: %v", err) + t.Errorf("encode response rpc: %v", err) } }() @@ -94,16 +99,16 @@ func TestDispatcherDispatchReturnsGatewayError(t *testing.T) { go func() { decoder := json.NewDecoder(serverConn) encoder := json.NewEncoder(serverConn) - var requestFrame gateway.MessageFrame - _ = decoder.Decode(&requestFrame) - _ = encoder.Encode(gateway.MessageFrame{ - Type: gateway.FrameTypeError, - Action: requestFrame.Action, - RequestID: requestFrame.RequestID, - Error: &gateway.FrameError{ - Code: gateway.ErrorCodeInvalidAction.String(), - Message: "unsupported wake action", - }, + var rpcRequest protocol.JSONRPCRequest + _ = decoder.Decode(&rpcRequest) + _ = encoder.Encode(protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: rpcRequest.ID, + Error: protocol.NewJSONRPCError( + protocol.JSONRPCCodeInvalidParams, + "unsupported wake action", + gateway.ErrorCodeInvalidAction.String(), + ), }) }() @@ -139,12 +144,16 @@ func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { go func() { decoder := json.NewDecoder(serverConn) encoder := json.NewEncoder(serverConn) - var requestFrame gateway.MessageFrame - _ = decoder.Decode(&requestFrame) - _ = encoder.Encode(gateway.MessageFrame{ - Type: gateway.FrameTypeEvent, - Action: requestFrame.Action, - RequestID: requestFrame.RequestID, + var rpcRequest protocol.JSONRPCRequest + _ = decoder.Decode(&rpcRequest) + _ = encoder.Encode(protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: rpcRequest.ID, + Result: gateway.MessageFrame{ + Type: gateway.FrameTypeEvent, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-3", + }, }) }() @@ -179,12 +188,16 @@ func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { go func() { decoder := json.NewDecoder(serverConn) encoder := json.NewEncoder(serverConn) - var requestFrame gateway.MessageFrame - _ = decoder.Decode(&requestFrame) - _ = encoder.Encode(gateway.MessageFrame{ - Type: gateway.FrameTypeAck, - Action: requestFrame.Action, - RequestID: "wake-mismatch", + var rpcRequest protocol.JSONRPCRequest + _ = decoder.Decode(&rpcRequest) + _ = encoder.Encode(protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: rpcRequest.ID, + Result: gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-mismatch", + }, }) }() @@ -302,9 +315,9 @@ func TestDispatcherDispatchInterruptsBlockedReadOnContextCancel(t *testing.T) { defer close(serverDone) decoder := json.NewDecoder(serverConn) - var frame gateway.MessageFrame - if err := decoder.Decode(&frame); err != nil { - t.Errorf("decode request frame: %v", err) + var rpcRequest protocol.JSONRPCRequest + if err := decoder.Decode(&rpcRequest); err != nil { + t.Errorf("decode request rpc: %v", err) return } close(requestArrived) @@ -573,12 +586,12 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { } }) - t.Run("gateway error frame missing payload", func(t *testing.T) { + t.Run("gateway response missing result payload", func(t *testing.T) { dispatcher := &Dispatcher{ resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, dialFn: func(string) (net.Conn, error) { return &stubDispatchConn{ - readBuffer: bytes.NewBufferString(`{"type":"error","action":"wake.openUrl","request_id":"wake-14"}` + "\n"), + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"wake-14"}` + "\n"), }, nil }, requestIDFn: func() string { return "wake-14" }, @@ -588,7 +601,158 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { RawURL: "neocode://review?path=README.md", }) if err == nil { - t.Fatal("expected missing error payload branch") + t.Fatal("expected missing result payload branch") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + + t.Run("response rpc version mismatch", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"1.0","id":"wake-15","result":{}}` + "\n"), + }, nil + }, + requestIDFn: func() string { return "wake-15" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected rpc version mismatch error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + + t.Run("response rpc id mismatch", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"wake-other","result":{}}` + "\n"), + }, nil + }, + requestIDFn: func() string { return "wake-16" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected rpc id mismatch error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + + t.Run("response contains both result and error", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString( + `{"jsonrpc":"2.0","id":"wake-17","result":{},"error":{"code":-32603,"message":"boom"}}` + "\n", + ), + }, nil + }, + requestIDFn: func() string { return "wake-17" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected both result and error payload failure") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + + t.Run("rpc error without gateway_code uses fallback code map", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString( + `{"jsonrpc":"2.0","id":"wake-18","error":{"code":-32601,"message":"method not found"}}` + "\n", + ), + }, nil + }, + requestIDFn: func() string { return "wake-18" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected rpc error mapping failure") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != gateway.ErrorCodeUnsupportedAction.String() { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeUnsupportedAction.String()) + } + }) + + t.Run("rpc error with empty message uses fallback text", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"wake-19","error":{"code":-32603,"message":""}}` + "\n"), + }, nil + }, + requestIDFn: func() string { return "wake-19" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected rpc error mapping failure") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != gateway.ErrorCodeInternalError.String() { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInternalError.String()) + } + if !strings.Contains(dispatchErr.Message, "empty rpc error message") { + t.Fatalf("error message = %q, want fallback text", dispatchErr.Message) + } + }) + + t.Run("decode response frame failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"wake-20","result":"not-frame"}` + "\n"), + }, nil + }, + requestIDFn: func() string { return "wake-20" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil { + t.Fatal("expected decode frame failure") } var dispatchErr *DispatchError if !errors.As(err, &dispatchErr) { @@ -600,6 +764,49 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { }) } +func TestDispatcherJSONRPCHelpers(t *testing.T) { + marshalErr := toDispatchErrorFromJSONRPC(&protocol.JSONRPCError{ + Code: protocol.JSONRPCCodeInternalError, + Message: "boom", + }) + var dispatchErr *DispatchError + if !errors.As(marshalErr, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", marshalErr) + } + if dispatchErr.Code != gateway.ErrorCodeInternalError.String() { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInternalError.String()) + } + + emptyErr := toDispatchErrorFromJSONRPC(nil) + if !errors.As(emptyErr, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", emptyErr) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + + if mapJSONRPCCodeToDispatchCode(protocol.JSONRPCCodeMethodNotFound) != gateway.ErrorCodeUnsupportedAction.String() { + t.Fatal("method_not_found should map to unsupported_action") + } + if mapJSONRPCCodeToDispatchCode(protocol.JSONRPCCodeInvalidParams) != gateway.ErrorCodeInvalidFrame.String() { + t.Fatal("invalid_params should map to invalid_frame") + } + if mapJSONRPCCodeToDispatchCode(123456) != ErrorCodeInternal { + t.Fatal("unknown rpc code should map to internal_error") + } + + if _, err := decodeResponseFrameResult(map[string]any{"bad": make(chan int)}); err == nil { + t.Fatal("expected decodeResponseFrameResult marshal failure") + } + if _, err := decodeResponseFrameResult("not-frame"); err == nil { + t.Fatal("expected decodeResponseFrameResult unmarshal failure") + } + + if _, err := marshalJSONRawMessage(make(chan int)); err == nil { + t.Fatal("expected marshalJSONRawMessage failure") + } +} + type stubDispatchConn struct { readBuffer *bytes.Buffer writeErr error diff --git a/internal/gateway/handlers/wake.go b/internal/gateway/handlers/wake.go index a8e4df55..82ea0624 100644 --- a/internal/gateway/handlers/wake.go +++ b/internal/gateway/handlers/wake.go @@ -88,7 +88,7 @@ func isSafeReviewPath(path string) bool { if trimmed == "" { return false } - if filepath.IsAbs(trimmed) { + if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") { return false } if containsParentTraversalSegment(trimmed) { diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go new file mode 100644 index 00000000..c5c5e5fb --- /dev/null +++ b/internal/gateway/protocol/jsonrpc.go @@ -0,0 +1,274 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "strings" +) + +const ( + // JSONRPCVersion 表示当前网关控制面固定使用的 JSON-RPC 协议版本。 + JSONRPCVersion = "2.0" +) + +const ( + // MethodGatewayPing 表示网关探活方法。 + MethodGatewayPing = "gateway.ping" + // MethodWakeOpenURL 表示 URL Scheme 唤醒方法。 + MethodWakeOpenURL = "wake.openUrl" +) + +const ( + // JSONRPCCodeParseError 表示请求体不是合法 JSON。 + JSONRPCCodeParseError = -32700 + // JSONRPCCodeInvalidRequest 表示请求结构不符合 JSON-RPC 规范。 + JSONRPCCodeInvalidRequest = -32600 + // JSONRPCCodeMethodNotFound 表示方法未注册。 + JSONRPCCodeMethodNotFound = -32601 + // JSONRPCCodeInvalidParams 表示参数不合法。 + JSONRPCCodeInvalidParams = -32602 + // JSONRPCCodeInternalError 表示服务端内部错误。 + JSONRPCCodeInternalError = -32603 +) + +const ( + // GatewayCodeInvalidFrame 表示请求帧结构非法。 + GatewayCodeInvalidFrame = "invalid_frame" + // GatewayCodeInvalidAction 表示动作参数非法。 + GatewayCodeInvalidAction = "invalid_action" + // GatewayCodeInvalidMultimodalPayload 表示多模态负载非法。 + GatewayCodeInvalidMultimodalPayload = "invalid_multimodal_payload" + // GatewayCodeMissingRequiredField 表示缺少必填字段。 + GatewayCodeMissingRequiredField = "missing_required_field" + // GatewayCodeUnsupportedAction 表示动作尚未实现。 + GatewayCodeUnsupportedAction = "unsupported_action" + // GatewayCodeInternalError 表示网关内部错误。 + GatewayCodeInternalError = "internal_error" + // GatewayCodeUnsafePath 表示路径存在安全风险。 + GatewayCodeUnsafePath = "unsafe_path" +) + +// JSONRPCRequest 表示控制面接收到的 JSON-RPC 请求。 +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// JSONRPCResponse 表示控制面输出的 JSON-RPC 响应。 +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +// JSONRPCError 表示 JSON-RPC 错误负载。 +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *JSONRPCErrorData `json:"data,omitempty"` +} + +// JSONRPCErrorData 表示网关扩展错误字段。 +type JSONRPCErrorData struct { + GatewayCode string `json:"gateway_code,omitempty"` +} + +// NormalizedRequest 表示从 JSON-RPC 归一化后的内部请求模型。 +type NormalizedRequest struct { + ID json.RawMessage + RequestID string + Action string + SessionID string + Workdir string + Payload any +} + +// NormalizeJSONRPCRequest 将 JSON-RPC 请求归一化为内部请求模型,并做方法级参数解析。 +func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRPCError) { + normalized := NormalizedRequest{} + + requestID, idErr := normalizeJSONRPCID(request.ID) + normalized.ID = cloneJSONRawMessage(request.ID) + normalized.RequestID = requestID + if idErr != nil { + return normalized, idErr + } + + if strings.TrimSpace(request.JSONRPC) != JSONRPCVersion { + return normalized, NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "invalid jsonrpc version", + GatewayCodeInvalidFrame, + ) + } + + method := strings.TrimSpace(request.Method) + if method == "" { + return normalized, NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "missing required field: method", + GatewayCodeMissingRequiredField, + ) + } + + switch method { + case MethodGatewayPing: + normalized.Action = "ping" + return normalized, nil + case MethodWakeOpenURL: + intent, parseErr := decodeWakeIntentParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = MethodWakeOpenURL + normalized.SessionID = strings.TrimSpace(intent.SessionID) + normalized.Workdir = strings.TrimSpace(intent.Workdir) + normalized.Payload = intent + return normalized, nil + default: + return normalized, NewJSONRPCError( + JSONRPCCodeMethodNotFound, + "method not found", + GatewayCodeUnsupportedAction, + ) + } +} + +// NewJSONRPCResultResponse 创建 JSON-RPC 成功响应。 +func NewJSONRPCResultResponse(id json.RawMessage, result any) JSONRPCResponse { + return JSONRPCResponse{ + JSONRPC: JSONRPCVersion, + ID: cloneJSONRawMessage(id), + Result: result, + } +} + +// NewJSONRPCErrorResponse 创建 JSON-RPC 错误响应。 +func NewJSONRPCErrorResponse(id json.RawMessage, rpcError *JSONRPCError) JSONRPCResponse { + return JSONRPCResponse{ + JSONRPC: JSONRPCVersion, + ID: cloneJSONRawMessage(id), + Error: rpcError, + } +} + +// NewJSONRPCError 创建带 gateway_code 的 JSON-RPC 错误对象。 +func NewJSONRPCError(code int, message, gatewayCode string) *JSONRPCError { + errorPayload := &JSONRPCError{ + Code: code, + Message: message, + } + if strings.TrimSpace(gatewayCode) != "" { + errorPayload.Data = &JSONRPCErrorData{GatewayCode: gatewayCode} + } + return errorPayload +} + +// GatewayCodeFromJSONRPCError 从 JSON-RPC 错误负载中提取稳定 gateway_code。 +func GatewayCodeFromJSONRPCError(rpcError *JSONRPCError) string { + if rpcError == nil || rpcError.Data == nil { + return "" + } + return strings.TrimSpace(rpcError.Data.GatewayCode) +} + +// MapGatewayCodeToJSONRPCCode 将稳定网关错误码映射到 JSON-RPC 错误码。 +func MapGatewayCodeToJSONRPCCode(gatewayCode string) int { + switch strings.TrimSpace(gatewayCode) { + case GatewayCodeUnsupportedAction: + return JSONRPCCodeMethodNotFound + case GatewayCodeInvalidAction, + GatewayCodeInvalidFrame, + GatewayCodeInvalidMultimodalPayload, + GatewayCodeMissingRequiredField, + GatewayCodeUnsafePath: + return JSONRPCCodeInvalidParams + case GatewayCodeInternalError: + return JSONRPCCodeInternalError + default: + return JSONRPCCodeInternalError + } +} + +// normalizeJSONRPCID 校验并提取请求 ID,确保控制面请求具备可关联标识。 +func normalizeJSONRPCID(id json.RawMessage) (string, *JSONRPCError) { + trimmed := bytes.TrimSpace(id) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "", NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "missing required field: id", + GatewayCodeMissingRequiredField, + ) + } + + if trimmed[0] == '"' { + var decoded string + if err := json.Unmarshal(trimmed, &decoded); err != nil { + return "", NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "invalid field: id", + GatewayCodeInvalidFrame, + ) + } + decoded = strings.TrimSpace(decoded) + if decoded == "" { + return "", NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "invalid field: id", + GatewayCodeInvalidFrame, + ) + } + return decoded, nil + } + + identifier := strings.TrimSpace(string(trimmed)) + if identifier == "" { + return "", NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "invalid field: id", + GatewayCodeInvalidFrame, + ) + } + return identifier, nil +} + +// decodeWakeIntentParams 对 wake.openUrl 的 params 执行延迟反序列化与最小校验。 +func decodeWakeIntentParams(raw json.RawMessage) (WakeIntent, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return WakeIntent{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) + } + + var intent WakeIntent + if err := json.Unmarshal(trimmed, &intent); err != nil { + return WakeIntent{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for wake.openUrl", + GatewayCodeInvalidFrame, + ) + } + intent.Action = strings.ToLower(strings.TrimSpace(intent.Action)) + intent.SessionID = strings.TrimSpace(intent.SessionID) + intent.Workdir = strings.TrimSpace(intent.Workdir) + if len(intent.Params) == 0 { + intent.Params = nil + } + return intent, nil +} + +// cloneJSONRawMessage 复制 RawMessage,避免共享底层切片导致的并发风险。 +func cloneJSONRawMessage(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + cloned := make([]byte, len(raw)) + copy(cloned, raw) + return json.RawMessage(cloned) +} diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go new file mode 100644 index 00000000..6fa58d31 --- /dev/null +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -0,0 +1,174 @@ +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestNormalizeJSONRPCRequestPing(t *testing.T) { + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"ping-1"`), + Method: MethodGatewayPing, + Params: json.RawMessage(`{}`), + }) + if rpcErr != nil { + t.Fatalf("normalize ping request: %v", rpcErr) + } + if normalized.RequestID != "ping-1" { + t.Fatalf("request_id = %q, want %q", normalized.RequestID, "ping-1") + } + if normalized.Action != "ping" { + t.Fatalf("action = %q, want %q", normalized.Action, "ping") + } +} + +func TestNormalizeJSONRPCRequestWakeOpenURL(t *testing.T) { + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"wake-1"`), + Method: MethodWakeOpenURL, + Params: json.RawMessage(`{ + "action":"review", + "session_id":"session-1", + "workdir":"/tmp/repo", + "params":{"path":"README.md"} + }`), + }) + if rpcErr != nil { + t.Fatalf("normalize wake request: %v", rpcErr) + } + if normalized.Action != MethodWakeOpenURL { + t.Fatalf("action = %q, want %q", normalized.Action, MethodWakeOpenURL) + } + if normalized.SessionID != "session-1" { + t.Fatalf("session_id = %q, want %q", normalized.SessionID, "session-1") + } + if normalized.Workdir != "/tmp/repo" { + t.Fatalf("workdir = %q, want %q", normalized.Workdir, "/tmp/repo") + } + intent, ok := normalized.Payload.(WakeIntent) + if !ok { + t.Fatalf("payload type = %T, want WakeIntent", normalized.Payload) + } + if intent.Params["path"] != "README.md" { + t.Fatalf("intent.params[path] = %q, want %q", intent.Params["path"], "README.md") + } +} + +func TestNormalizeJSONRPCRequestErrors(t *testing.T) { + testCases := []struct { + name string + request JSONRPCRequest + wantCode int + wantGatewayCode string + }{ + { + name: "missing id", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + Method: MethodGatewayPing, + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "invalid version", + request: JSONRPCRequest{ + JSONRPC: "1.0", + ID: json.RawMessage(`"x"`), + Method: MethodGatewayPing, + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "missing method", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "method not found", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: "gateway.unknown", + }, + wantCode: JSONRPCCodeMethodNotFound, + wantGatewayCode: GatewayCodeUnsupportedAction, + }, + { + name: "wake missing params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodWakeOpenURL, + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "wake invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodWakeOpenURL, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, rpcErr := NormalizeJSONRPCRequest(tc.request) + if rpcErr == nil { + t.Fatal("expected rpc error") + } + if rpcErr.Code != tc.wantCode { + t.Fatalf("rpc code = %d, want %d", rpcErr.Code, tc.wantCode) + } + if gatewayCode := GatewayCodeFromJSONRPCError(rpcErr); gatewayCode != tc.wantGatewayCode { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, tc.wantGatewayCode) + } + }) + } +} + +func TestJSONRPCHelpers(t *testing.T) { + response := NewJSONRPCResultResponse(json.RawMessage(`"req-1"`), map[string]string{"message": "ok"}) + if response.JSONRPC != JSONRPCVersion { + t.Fatalf("jsonrpc = %q, want %q", response.JSONRPC, JSONRPCVersion) + } + if string(response.ID) != `"req-1"` { + t.Fatalf("id = %s, want %s", response.ID, `"req-1"`) + } + + rpcErr := NewJSONRPCError(JSONRPCCodeInternalError, "boom", GatewayCodeInternalError) + errorResponse := NewJSONRPCErrorResponse(json.RawMessage(`"req-2"`), rpcErr) + if errorResponse.Error == nil { + t.Fatal("error response should include rpc error payload") + } + if GatewayCodeFromJSONRPCError(errorResponse.Error) != GatewayCodeInternalError { + t.Fatalf("gateway_code = %q, want %q", GatewayCodeFromJSONRPCError(errorResponse.Error), GatewayCodeInternalError) + } + if GatewayCodeFromJSONRPCError(nil) != "" { + t.Fatal("gateway_code for nil rpc error should be empty") + } + + if MapGatewayCodeToJSONRPCCode(GatewayCodeUnsupportedAction) != JSONRPCCodeMethodNotFound { + t.Fatal("unsupported_action should map to method_not_found") + } + if MapGatewayCodeToJSONRPCCode(GatewayCodeInvalidAction) != JSONRPCCodeInvalidParams { + t.Fatal("invalid_action should map to invalid_params") + } + if MapGatewayCodeToJSONRPCCode("unknown") != JSONRPCCodeInternalError { + t.Fatal("unknown code should map to internal_error") + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 718dc430..8871873e 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "neo-code/internal/gateway/protocol" "neo-code/internal/gateway/transport" ) @@ -261,7 +262,7 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, runtimePor return } - frame, err := decodeFrame(reader) + rpcRequest, err := decodeRPCRequest(reader) if err != nil { if errors.Is(err, io.EOF) { return @@ -275,20 +276,31 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, runtimePor } if errors.Is(err, errFrameTooLarge) { s.logger.Printf("decode frame failed: %v", err) - _ = s.writeFrame(conn, encoder, errorFrame(MessageFrame{}, NewFrameError( - ErrorCodeInvalidFrame, - fmt.Sprintf("frame exceeds max size %d bytes", MaxFrameSize), - ))) + _ = s.writeRPCResponse(conn, encoder, protocol.NewJSONRPCErrorResponse( + nil, + protocol.NewJSONRPCError( + protocol.JSONRPCCodeInvalidParams, + fmt.Sprintf("frame exceeds max size %d bytes", MaxFrameSize), + protocol.GatewayCodeInvalidFrame, + ), + )) return } s.logger.Printf("decode frame failed: %v", err) - _ = s.writeFrame(conn, encoder, errorFrame(MessageFrame{}, NewFrameError(ErrorCodeInvalidFrame, "invalid json frame"))) + _ = s.writeRPCResponse(conn, encoder, protocol.NewJSONRPCErrorResponse( + nil, + protocol.NewJSONRPCError( + protocol.JSONRPCCodeParseError, + "invalid json-rpc request", + protocol.GatewayCodeInvalidFrame, + ), + )) return } - response := s.dispatchFrame(ctx, frame, runtimePort) - if !s.writeFrame(conn, encoder, response) { + rpcResponse := s.dispatchRPCRequest(ctx, rpcRequest, runtimePort) + if !s.writeRPCResponse(conn, encoder, rpcResponse) { return } } @@ -310,13 +322,13 @@ func (s *Server) applyWriteDeadline(conn net.Conn) error { return conn.SetWriteDeadline(time.Now().Add(s.writeTimeout)) } -// writeFrame 统一处理响应写回及写超时设置,失败时返回 false 供上层快速终止连接循环。 -func (s *Server) writeFrame(conn net.Conn, encoder *json.Encoder, frame MessageFrame) bool { +// writeRPCResponse 统一处理 JSON-RPC 响应写回及写超时设置,失败时返回 false 供上层快速终止连接循环。 +func (s *Server) writeRPCResponse(conn net.Conn, encoder *json.Encoder, response protocol.JSONRPCResponse) bool { if err := s.applyWriteDeadline(conn); err != nil { s.logger.Printf("set write deadline failed: %v", err) return false } - if err := encoder.Encode(frame); err != nil { + if err := encoder.Encode(response); err != nil { s.logger.Printf("write frame failed: %v", err) return false } @@ -329,27 +341,66 @@ func isTimeoutError(err error) bool { return errors.As(err, &netErr) && netErr.Timeout() } -// decodeFrame 从连接读取一条 JSON 帧并执行长度与格式校验。 -func decodeFrame(reader *bufio.Reader) (MessageFrame, error) { +// decodeRPCRequest 从连接读取一条 JSON-RPC 请求并执行长度与格式校验。 +func decodeRPCRequest(reader *bufio.Reader) (protocol.JSONRPCRequest, error) { payload, err := readFramePayload(reader, MaxFrameSize) if err != nil { - return MessageFrame{}, err + return protocol.JSONRPCRequest{}, err } limitedReader := &io.LimitedReader{R: bytes.NewReader(payload), N: MaxFrameSize} decoder := json.NewDecoder(limitedReader) - var frame MessageFrame - if err := decoder.Decode(&frame); err != nil { - return MessageFrame{}, err + var request protocol.JSONRPCRequest + if err := decoder.Decode(&request); err != nil { + return protocol.JSONRPCRequest{}, err } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return MessageFrame{}, fmt.Errorf("frame contains trailing json values") + return protocol.JSONRPCRequest{}, fmt.Errorf("frame contains trailing json values") } - return frame, nil + return request, nil +} + +// dispatchRPCRequest 将 JSON-RPC 请求归一化为 MessageFrame,并复用既有分发逻辑处理。 +func (s *Server) dispatchRPCRequest( + ctx context.Context, + request protocol.JSONRPCRequest, + runtimePort RuntimePort, +) protocol.JSONRPCResponse { + normalized, rpcErr := protocol.NormalizeJSONRPCRequest(request) + if rpcErr != nil { + return protocol.NewJSONRPCErrorResponse(normalized.ID, rpcErr) + } + + frame := MessageFrame{ + Type: FrameTypeRequest, + Action: FrameAction(normalized.Action), + RequestID: normalized.RequestID, + SessionID: normalized.SessionID, + Workdir: normalized.Workdir, + Payload: normalized.Payload, + } + + responseFrame := s.dispatchFrame(ctx, frame, runtimePort) + if responseFrame.Type != FrameTypeError { + return protocol.NewJSONRPCResultResponse(normalized.ID, responseFrame) + } + + frameErr := responseFrame.Error + if frameErr == nil { + frameErr = NewFrameError(ErrorCodeInternalError, "gateway response missing error payload") + } + return protocol.NewJSONRPCErrorResponse( + normalized.ID, + protocol.NewJSONRPCError( + protocol.MapGatewayCodeToJSONRPCCode(frameErr.Code), + frameErr.Message, + frameErr.Code, + ), + ) } // readFramePayload 按换行边界读取单条帧,并限制单帧最大字节数。 diff --git a/internal/gateway/server_additional_test.go b/internal/gateway/server_additional_test.go index d5eea3d5..080b58e3 100644 --- a/internal/gateway/server_additional_test.go +++ b/internal/gateway/server_additional_test.go @@ -12,6 +12,8 @@ import ( "sync" "testing" "time" + + "neo-code/internal/gateway/protocol" ) func TestNewServerUsesDefaultsAndOverrides(t *testing.T) { @@ -236,9 +238,9 @@ func TestCloseReturnsContextErrorWhenWaitCanceled(t *testing.T) { server.wg.Done() } -func TestDecodeFrameTrailingJSON(t *testing.T) { - reader := bufio.NewReader(strings.NewReader(`{"type":"request","action":"ping"} {"extra":1}` + "\n")) - _, err := decodeFrame(reader) +func TestDecodeRPCRequestTrailingJSON(t *testing.T) { + reader := bufio.NewReader(strings.NewReader(`{"jsonrpc":"2.0","id":"x","method":"gateway.ping"} {"extra":1}` + "\n")) + _, err := decodeRPCRequest(reader) if err == nil || !strings.Contains(err.Error(), "trailing") { t.Fatalf("expected trailing json error, got %v", err) } @@ -289,6 +291,56 @@ func TestDispatchFrameValidationError(t *testing.T) { } } +func TestDispatchRPCRequestNormalizeError(t *testing.T) { + server := &Server{} + response := server.dispatchRPCRequest(context.Background(), protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + Method: protocol.MethodGatewayPing, + }, nil) + if response.Error == nil { + t.Fatal("expected rpc normalize error") + } + if response.Error.Code != protocol.JSONRPCCodeInvalidRequest { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidRequest) + } + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeMissingRequiredField.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeMissingRequiredField.String()) + } +} + +func TestDispatchRPCRequestConvertsFrameErrorWithoutPayload(t *testing.T) { + server := &Server{} + originalHandlers := requestFrameHandlers + requestFrameHandlers = map[FrameAction]requestFrameHandler{ + FrameActionPing: func(frame MessageFrame) MessageFrame { + return MessageFrame{ + Type: FrameTypeError, + Action: frame.Action, + RequestID: frame.RequestID, + Error: nil, + } + }, + } + t.Cleanup(func() { + requestFrameHandlers = originalHandlers + }) + + response := server.dispatchRPCRequest(context.Background(), protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"rpc-err-1"`), + Method: protocol.MethodGatewayPing, + }, nil) + if response.Error == nil { + t.Fatal("expected rpc error response") + } + if response.Error.Code != protocol.JSONRPCCodeInternalError { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInternalError) + } + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeInternalError.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeInternalError.String()) + } +} + func TestServerHandleConnectionSkipsEmptyFrame(t *testing.T) { server := &Server{logger: log.New(io.Discard, "", 0)} serverConn, clientConn := net.Pipe() @@ -299,15 +351,22 @@ func TestServerHandleConnectionSkipsEmptyFrame(t *testing.T) { }() _, _ = io.WriteString(clientConn, "\n") - _, _ = io.WriteString(clientConn, `{"type":"request","action":"ping","request_id":"empty-then-ping"}`+"\n") + _, _ = io.WriteString(clientConn, `{"jsonrpc":"2.0","id":"empty-then-ping","method":"gateway.ping","params":{}}`+"\n") decoder := json.NewDecoder(clientConn) - var response MessageFrame + var response protocol.JSONRPCResponse if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if response.Type != FrameTypeAck || response.Action != FrameActionPing { - t.Fatalf("unexpected response after empty frame: %#v", response) + if response.Error != nil { + t.Fatalf("unexpected rpc error after empty frame: %+v", response.Error) + } + resultFrame, err := decodeJSONRPCResultFrame(response) + if err != nil { + t.Fatalf("decode result frame: %v", err) + } + if resultFrame.Type != FrameTypeAck || resultFrame.Action != FrameActionPing { + t.Fatalf("unexpected response after empty frame: %#v", resultFrame) } _ = clientConn.Close() @@ -329,15 +388,18 @@ func TestServerHandleConnectionInvalidJSONFrame(t *testing.T) { _, _ = io.WriteString(clientConn, "{invalid-json}\n") decoder := json.NewDecoder(clientConn) - var response MessageFrame + var response protocol.JSONRPCResponse if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if response.Type != FrameTypeError { - t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + if response.Error == nil { + t.Fatal("response rpc error is nil") } - if response.Error == nil || response.Error.Code != ErrorCodeInvalidFrame.String() { - t.Fatalf("response error = %#v, want invalid frame", response.Error) + if response.Error.Code != protocol.JSONRPCCodeParseError { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeParseError) + } + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeInvalidFrame.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeInvalidFrame.String()) } _ = clientConn.Close() @@ -412,7 +474,7 @@ func TestServerHandleConnectionWriteTimeoutClosesConnection(t *testing.T) { server.handleConnection(context.Background(), serverConn, nil) }() - _, err := io.WriteString(clientConn, `{"type":"request","action":"ping","request_id":"write-timeout"}`+"\n") + _, err := io.WriteString(clientConn, `{"jsonrpc":"2.0","id":"write-timeout","method":"gateway.ping","params":{}}`+"\n") if err != nil { t.Fatalf("write request: %v", err) } diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go index aa7cae26..2fedea0d 100644 --- a/internal/gateway/server_test.go +++ b/internal/gateway/server_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" "time" + + "neo-code/internal/gateway/protocol" ) func TestServerHandleConnectionPing(t *testing.T) { @@ -28,32 +30,40 @@ func TestServerHandleConnectionPing(t *testing.T) { encoder := json.NewEncoder(clientConn) decoder := json.NewDecoder(clientConn) - if err := encoder.Encode(MessageFrame{ - Type: FrameTypeRequest, - Action: FrameActionPing, - RequestID: "req-1", + if err := encoder.Encode(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-1"`), + Method: protocol.MethodGatewayPing, + Params: json.RawMessage(`{}`), }); err != nil { t.Fatalf("encode request: %v", err) } - var response MessageFrame + var response protocol.JSONRPCResponse if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } + if response.Error != nil { + t.Fatalf("unexpected rpc error: %+v", response.Error) + } + resultFrame, err := decodeJSONRPCResultFrame(response) + if err != nil { + t.Fatalf("decode result frame: %v", err) + } - if response.Type != FrameTypeAck { - t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + if resultFrame.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", resultFrame.Type, FrameTypeAck) } - if response.Action != FrameActionPing { - t.Fatalf("response action = %q, want %q", response.Action, FrameActionPing) + if resultFrame.Action != FrameActionPing { + t.Fatalf("response action = %q, want %q", resultFrame.Action, FrameActionPing) } - if response.RequestID != "req-1" { - t.Fatalf("response request_id = %q, want %q", response.RequestID, "req-1") + if resultFrame.RequestID != "req-1" { + t.Fatalf("response request_id = %q, want %q", resultFrame.RequestID, "req-1") } - payloadMap, ok := response.Payload.(map[string]any) + payloadMap, ok := resultFrame.Payload.(map[string]any) if !ok { - t.Fatalf("response payload type = %T, want map[string]any", response.Payload) + t.Fatalf("response payload type = %T, want map[string]any", resultFrame.Payload) } if got, _ := payloadMap["message"].(string); got != "pong" { t.Fatalf("response payload message = %q, want %q", got, "pong") @@ -82,28 +92,27 @@ func TestServerHandleConnectionUnsupportedAction(t *testing.T) { encoder := json.NewEncoder(clientConn) decoder := json.NewDecoder(clientConn) - if err := encoder.Encode(MessageFrame{ - Type: FrameTypeRequest, - Action: FrameActionRun, - RequestID: "req-2", - InputText: "hello", + if err := encoder.Encode(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-2"`), + Method: "gateway.run", + Params: json.RawMessage(`{"input_text":"hello"}`), }); err != nil { t.Fatalf("encode request: %v", err) } - var response MessageFrame + var response protocol.JSONRPCResponse if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - - if response.Type != FrameTypeError { - t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) - } if response.Error == nil { - t.Fatal("response error is nil") + t.Fatal("response rpc error is nil") + } + if response.Error.Code != protocol.JSONRPCCodeMethodNotFound { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeMethodNotFound) } - if response.Error.Code != ErrorCodeUnsupportedAction.String() { - t.Fatalf("error code = %q, want %q", response.Error.Code, ErrorCodeUnsupportedAction.String()) + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeUnsupportedAction.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeUnsupportedAction.String()) } _ = clientConn.Close() @@ -129,7 +138,7 @@ func TestServerHandleConnectionRejectsOversizedFrame(t *testing.T) { decoder := json.NewDecoder(clientConn) oversizedPayload := strings.Repeat("a", int(MaxFrameSize)+128) requestFrame := fmt.Sprintf( - `{"type":"request","action":"ping","request_id":"req-oversize","input_text":"%s"}`+"\n", + `{"jsonrpc":"2.0","id":"req-oversize","method":"gateway.ping","params":{"input_text":"%s"}}`+"\n", oversizedPayload, ) @@ -139,18 +148,18 @@ func TestServerHandleConnectionRejectsOversizedFrame(t *testing.T) { writeDone <- err }() - var response MessageFrame + var response protocol.JSONRPCResponse if err := decoder.Decode(&response); err != nil { t.Fatalf("decode oversized response: %v", err) } - if response.Type != FrameTypeError { - t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) - } if response.Error == nil { - t.Fatal("response error is nil") + t.Fatal("response rpc error is nil") } - if response.Error.Code != ErrorCodeInvalidFrame.String() { - t.Fatalf("error code = %q, want %q", response.Error.Code, ErrorCodeInvalidFrame.String()) + if response.Error.Code != protocol.JSONRPCCodeInvalidParams { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidParams) + } + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeInvalidFrame.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeInvalidFrame.String()) } if !strings.Contains(response.Error.Message, "frame exceeds max size") { t.Fatalf("error message = %q, want contains %q", response.Error.Message, "frame exceeds max size") @@ -189,3 +198,18 @@ func TestServerHandleConnectionRejectsOversizedFrame(t *testing.T) { t.Fatal("handleConnection did not exit") } } + +func decodeJSONRPCResultFrame(response protocol.JSONRPCResponse) (MessageFrame, error) { + if response.Result == nil { + return MessageFrame{}, errors.New("rpc result is nil") + } + raw, err := json.Marshal(response.Result) + if err != nil { + return MessageFrame{}, err + } + var frame MessageFrame + if err := json.Unmarshal(raw, &frame); err != nil { + return MessageFrame{}, err + } + return frame, nil +} From 9ee41ed4f3fd8d1fac2830a9f8cb641c931eb051 Mon Sep 17 00:00:00 2001 From: pionxe Date: Wed, 15 Apr 2026 12:02:52 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=20JSON-RPC=20ID=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8D=E4=B8=A5=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E3=80=81Windows=20=E8=B7=AF=E5=BE=84=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E5=A2=9E=E5=BC=BA=E5=92=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20Result=20=E8=A7=A3=E7=A0=81=E7=9A=84=E6=80=A7=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=A4=9A=E4=BD=99=E7=9A=84=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/adapters/urlscheme/dispatcher.go | 8 +-- .../adapters/urlscheme/dispatcher_test.go | 30 +++++--- internal/gateway/handlers/wake.go | 12 ++++ internal/gateway/handlers/wake_test.go | 6 ++ internal/gateway/protocol/jsonrpc.go | 50 ++++++++----- internal/gateway/protocol/jsonrpc_test.go | 70 ++++++++++++++++++- internal/gateway/server.go | 6 +- internal/gateway/server_test.go | 6 +- 8 files changed, 148 insertions(+), 40 deletions(-) diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 74ab7ccd..32fa9d2f 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -276,13 +276,9 @@ func mapJSONRPCCodeToDispatchCode(code int) string { } // decodeResponseFrameResult 将 JSON-RPC result 安全解码回 MessageFrame。 -func decodeResponseFrameResult(result any) (gateway.MessageFrame, error) { - raw, err := json.Marshal(result) - if err != nil { - return gateway.MessageFrame{}, err - } +func decodeResponseFrameResult(result json.RawMessage) (gateway.MessageFrame, error) { var frame gateway.MessageFrame - if err := json.Unmarshal(raw, &frame); err != nil { + if err := json.Unmarshal(result, &frame); err != nil { return gateway.MessageFrame{}, err } return frame, nil diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index e52569c6..1fd9d3d0 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -54,14 +54,14 @@ func TestDispatcherDispatchSuccess(t *testing.T) { if err := encoder.Encode(protocol.JSONRPCResponse{ JSONRPC: protocol.JSONRPCVersion, ID: rpcRequest.ID, - Result: gateway.MessageFrame{ + Result: mustMarshalRawJSON(t, gateway.MessageFrame{ Type: gateway.FrameTypeAck, Action: gateway.FrameActionWakeOpenURL, RequestID: "wake-1", Payload: map[string]any{ "message": "wake intent accepted", }, - }, + }), }); err != nil { t.Errorf("encode response rpc: %v", err) } @@ -149,11 +149,11 @@ func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { _ = encoder.Encode(protocol.JSONRPCResponse{ JSONRPC: protocol.JSONRPCVersion, ID: rpcRequest.ID, - Result: gateway.MessageFrame{ + Result: mustMarshalRawJSON(t, gateway.MessageFrame{ Type: gateway.FrameTypeEvent, Action: gateway.FrameActionWakeOpenURL, RequestID: "wake-3", - }, + }), }) }() @@ -193,11 +193,11 @@ func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { _ = encoder.Encode(protocol.JSONRPCResponse{ JSONRPC: protocol.JSONRPCVersion, ID: rpcRequest.ID, - Result: gateway.MessageFrame{ + Result: mustMarshalRawJSON(t, gateway.MessageFrame{ Type: gateway.FrameTypeAck, Action: gateway.FrameActionWakeOpenURL, RequestID: "wake-mismatch", - }, + }), }) }() @@ -795,12 +795,12 @@ func TestDispatcherJSONRPCHelpers(t *testing.T) { t.Fatal("unknown rpc code should map to internal_error") } - if _, err := decodeResponseFrameResult(map[string]any{"bad": make(chan int)}); err == nil { - t.Fatal("expected decodeResponseFrameResult marshal failure") - } - if _, err := decodeResponseFrameResult("not-frame"); err == nil { + if _, err := decodeResponseFrameResult(json.RawMessage(`"not-frame"`)); err == nil { t.Fatal("expected decodeResponseFrameResult unmarshal failure") } + if _, err := decodeResponseFrameResult(json.RawMessage(`{"type":"ack","action":"wake.openUrl","request_id":"x"`)); err == nil { + t.Fatal("expected decodeResponseFrameResult malformed json failure") + } if _, err := marshalJSONRawMessage(make(chan int)); err == nil { t.Fatal("expected marshalJSONRawMessage failure") @@ -868,3 +868,13 @@ func (a stubDispatchAddr) Network() string { func (a stubDispatchAddr) String() string { return string(a) } + +func mustMarshalRawJSON(t *testing.T, payload any) json.RawMessage { + t.Helper() + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal raw json: %v", err) + } + return json.RawMessage(raw) +} diff --git a/internal/gateway/handlers/wake.go b/internal/gateway/handlers/wake.go index 82ea0624..96ce6246 100644 --- a/internal/gateway/handlers/wake.go +++ b/internal/gateway/handlers/wake.go @@ -88,6 +88,12 @@ func isSafeReviewPath(path string) bool { if trimmed == "" { return false } + if filepath.VolumeName(trimmed) != "" { + return false + } + if hasBlockedWindowsPathPrefix(trimmed) { + return false + } if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") { return false } @@ -104,6 +110,12 @@ func isSafeReviewPath(path string) bool { return true } +// hasBlockedWindowsPathPrefix 检查是否命中 Windows 底层设备路径前缀,避免绕过常规路径校验。 +func hasBlockedWindowsPathPrefix(path string) bool { + normalized := strings.ReplaceAll(strings.TrimSpace(path), "/", "\\") + return strings.HasPrefix(normalized, `\\?\`) || strings.HasPrefix(normalized, `\\.\`) +} + // containsParentTraversalSegment 按路径段语义识别目录回退段,避免子串匹配导致误伤。 func containsParentTraversalSegment(path string) bool { normalized := normalizePath(path) diff --git a/internal/gateway/handlers/wake_test.go b/internal/gateway/handlers/wake_test.go index f5184a26..3941bf6b 100644 --- a/internal/gateway/handlers/wake_test.go +++ b/internal/gateway/handlers/wake_test.go @@ -59,6 +59,9 @@ func TestWakeOpenURLHandlerHandleUnsafePath(t *testing.T) { "../../etc/passwd", "/etc/passwd", "..\\Windows\\system32", + "C:foo", + `\\?\C:\Windows\System32`, + `\\.\pipe\neocode`, } handler := NewWakeOpenURLHandler() @@ -90,6 +93,9 @@ func TestIsSafeReviewPath(t *testing.T) { {name: "parent traversal", path: "../secret.txt", want: false}, {name: "parent traversal nested", path: "a/../../secret.txt", want: false}, {name: "absolute unix path", path: "/tmp/file", want: false}, + {name: "windows drive relative path", path: "C:foo", want: false}, + {name: "windows device path namespace", path: `\\?\C:\tmp\file`, want: false}, + {name: "windows device pipe namespace", path: `\\.\pipe\name`, want: false}, {name: "empty", path: "", want: false}, {name: "dot current dir", path: ".", want: false}, } diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index c5c5e5fb..66773148 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -60,7 +60,7 @@ type JSONRPCRequest struct { type JSONRPCResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` - Result any `json:"result,omitempty"` + Result json.RawMessage `json:"result,omitempty"` Error *JSONRPCError `json:"error,omitempty"` } @@ -137,13 +137,22 @@ func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRP } } -// NewJSONRPCResultResponse 创建 JSON-RPC 成功响应。 -func NewJSONRPCResultResponse(id json.RawMessage, result any) JSONRPCResponse { +// NewJSONRPCResultResponse 创建 JSON-RPC 成功响应,并将 result 编码为 RawMessage。 +func NewJSONRPCResultResponse(id json.RawMessage, result any) (JSONRPCResponse, *JSONRPCError) { + rawResult, err := json.Marshal(result) + if err != nil { + return JSONRPCResponse{}, NewJSONRPCError( + JSONRPCCodeInternalError, + "failed to encode jsonrpc result", + GatewayCodeInternalError, + ) + } + return JSONRPCResponse{ JSONRPC: JSONRPCVersion, ID: cloneJSONRawMessage(id), - Result: result, - } + Result: json.RawMessage(rawResult), + }, nil } // NewJSONRPCErrorResponse 创建 JSON-RPC 错误响应。 @@ -204,35 +213,44 @@ func normalizeJSONRPCID(id json.RawMessage) (string, *JSONRPCError) { ) } - if trimmed[0] == '"' { - var decoded string - if err := json.Unmarshal(trimmed, &decoded); err != nil { + var decoded any + if err := json.Unmarshal(trimmed, &decoded); err != nil { + return "", NewJSONRPCError( + JSONRPCCodeInvalidRequest, + "invalid field: id", + GatewayCodeInvalidFrame, + ) + } + + switch typedID := decoded.(type) { + case string: + typedID = strings.TrimSpace(typedID) + if typedID == "" { return "", NewJSONRPCError( JSONRPCCodeInvalidRequest, "invalid field: id", GatewayCodeInvalidFrame, ) } - decoded = strings.TrimSpace(decoded) - if decoded == "" { + return typedID, nil + case float64: + identifier := strings.TrimSpace(string(trimmed)) + if identifier == "" { return "", NewJSONRPCError( JSONRPCCodeInvalidRequest, "invalid field: id", GatewayCodeInvalidFrame, ) } - return decoded, nil - } - - identifier := strings.TrimSpace(string(trimmed)) - if identifier == "" { + _ = typedID + return identifier, nil + default: return "", NewJSONRPCError( JSONRPCCodeInvalidRequest, "invalid field: id", GatewayCodeInvalidFrame, ) } - return identifier, nil } // decodeWakeIntentParams 对 wake.openUrl 的 params 执行延迟反序列化与最小校验。 diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 6fa58d31..2d9b37e0 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -23,6 +23,21 @@ func TestNormalizeJSONRPCRequestPing(t *testing.T) { } } +func TestNormalizeJSONRPCRequestPingWithNumericID(t *testing.T) { + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`123`), + Method: MethodGatewayPing, + Params: json.RawMessage(`{}`), + }) + if rpcErr != nil { + t.Fatalf("normalize ping request with numeric id: %v", rpcErr) + } + if normalized.RequestID != "123" { + t.Fatalf("request_id = %q, want %q", normalized.RequestID, "123") + } +} + func TestNormalizeJSONRPCRequestWakeOpenURL(t *testing.T) { normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ JSONRPC: JSONRPCVersion, @@ -82,6 +97,36 @@ func TestNormalizeJSONRPCRequestErrors(t *testing.T) { wantCode: JSONRPCCodeInvalidRequest, wantGatewayCode: GatewayCodeInvalidFrame, }, + { + name: "invalid id object", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`{}`), + Method: MethodGatewayPing, + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "invalid id array", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`[]`), + Method: MethodGatewayPing, + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "invalid id boolean", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`true`), + Method: MethodGatewayPing, + }, + wantCode: JSONRPCCodeInvalidRequest, + wantGatewayCode: GatewayCodeInvalidFrame, + }, { name: "missing method", request: JSONRPCRequest{ @@ -142,15 +187,36 @@ func TestNormalizeJSONRPCRequestErrors(t *testing.T) { } func TestJSONRPCHelpers(t *testing.T) { - response := NewJSONRPCResultResponse(json.RawMessage(`"req-1"`), map[string]string{"message": "ok"}) + response, rpcErr := NewJSONRPCResultResponse(json.RawMessage(`"req-1"`), map[string]string{"message": "ok"}) + if rpcErr != nil { + t.Fatalf("new jsonrpc result response: %v", rpcErr) + } if response.JSONRPC != JSONRPCVersion { t.Fatalf("jsonrpc = %q, want %q", response.JSONRPC, JSONRPCVersion) } if string(response.ID) != `"req-1"` { t.Fatalf("id = %s, want %s", response.ID, `"req-1"`) } + var result map[string]string + if err := json.Unmarshal(response.Result, &result); err != nil { + t.Fatalf("decode result raw message: %v", err) + } + if result["message"] != "ok" { + t.Fatalf(`result["message"] = %q, want %q`, result["message"], "ok") + } + + _, rpcErr = NewJSONRPCResultResponse(json.RawMessage(`"req-chan"`), map[string]any{"bad": make(chan int)}) + if rpcErr == nil { + t.Fatal("expected result encode error") + } + if rpcErr.Code != JSONRPCCodeInternalError { + t.Fatalf("rpc code = %d, want %d", rpcErr.Code, JSONRPCCodeInternalError) + } + if gatewayCode := GatewayCodeFromJSONRPCError(rpcErr); gatewayCode != GatewayCodeInternalError { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, GatewayCodeInternalError) + } - rpcErr := NewJSONRPCError(JSONRPCCodeInternalError, "boom", GatewayCodeInternalError) + rpcErr = NewJSONRPCError(JSONRPCCodeInternalError, "boom", GatewayCodeInternalError) errorResponse := NewJSONRPCErrorResponse(json.RawMessage(`"req-2"`), rpcErr) if errorResponse.Error == nil { t.Fatal("error response should include rpc error payload") diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 8871873e..ce3c902a 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -386,7 +386,11 @@ func (s *Server) dispatchRPCRequest( responseFrame := s.dispatchFrame(ctx, frame, runtimePort) if responseFrame.Type != FrameTypeError { - return protocol.NewJSONRPCResultResponse(normalized.ID, responseFrame) + rpcResponse, encodeErr := protocol.NewJSONRPCResultResponse(normalized.ID, responseFrame) + if encodeErr != nil { + return protocol.NewJSONRPCErrorResponse(normalized.ID, encodeErr) + } + return rpcResponse } frameErr := responseFrame.Error diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go index 2fedea0d..a65817d2 100644 --- a/internal/gateway/server_test.go +++ b/internal/gateway/server_test.go @@ -203,12 +203,8 @@ func decodeJSONRPCResultFrame(response protocol.JSONRPCResponse) (MessageFrame, if response.Result == nil { return MessageFrame{}, errors.New("rpc result is nil") } - raw, err := json.Marshal(response.Result) - if err != nil { - return MessageFrame{}, err - } var frame MessageFrame - if err := json.Unmarshal(raw, &frame); err != nil { + if err := json.Unmarshal(response.Result, &frame); err != nil { return MessageFrame{}, err } return frame, nil From bd9cca638b8e6ac2bfdae6f91c4d53c6e975b978 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 05:30:45 +0000 Subject: [PATCH 3/4] fix(gateway): resolve remaining JSON-RPC hardening review gaps - block Windows drive-relative review paths across platforms - ensure JSON-RPC error responses keep id field (null when unknown) - strengthen dispatcher success-case RPC envelope assertions Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- .../adapters/urlscheme/dispatcher_test.go | 21 +++++++++++++++++++ internal/gateway/handlers/wake.go | 13 ++++++++++++ internal/gateway/protocol/jsonrpc.go | 2 +- internal/gateway/protocol/jsonrpc_test.go | 19 +++++++++++++++++ internal/gateway/server_additional_test.go | 3 +++ 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 1fd9d3d0..75f1427c 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -50,6 +50,27 @@ func TestDispatcherDispatchSuccess(t *testing.T) { t.Errorf("request method = %q, want %q", rpcRequest.Method, protocol.MethodWakeOpenURL) return } + if rpcRequest.JSONRPC != protocol.JSONRPCVersion { + t.Errorf("request jsonrpc = %q, want %q", rpcRequest.JSONRPC, protocol.JSONRPCVersion) + return + } + if len(bytes.TrimSpace(rpcRequest.ID)) == 0 { + t.Error("request id should not be empty") + return + } + var params protocol.WakeIntent + if err := json.Unmarshal(rpcRequest.Params, ¶ms); err != nil { + t.Errorf("decode request params: %v", err) + return + } + if params.Action != protocol.WakeActionReview { + t.Errorf("request params action = %q, want %q", params.Action, protocol.WakeActionReview) + return + } + if got := params.Params["path"]; got != "README.md" { + t.Errorf("request params[path] = %q, want %q", got, "README.md") + return + } if err := encoder.Encode(protocol.JSONRPCResponse{ JSONRPC: protocol.JSONRPCVersion, diff --git a/internal/gateway/handlers/wake.go b/internal/gateway/handlers/wake.go index 96ce6246..db7d7f40 100644 --- a/internal/gateway/handlers/wake.go +++ b/internal/gateway/handlers/wake.go @@ -88,6 +88,9 @@ func isSafeReviewPath(path string) bool { if trimmed == "" { return false } + if hasWindowsDriveLetterPrefix(trimmed) { + return false + } if filepath.VolumeName(trimmed) != "" { return false } @@ -110,6 +113,16 @@ func isSafeReviewPath(path string) bool { return true } +// hasWindowsDriveLetterPrefix 检查是否为 Windows 盘符前缀路径(如 C:foo),避免平台差异导致漏拦截。 +func hasWindowsDriveLetterPrefix(path string) bool { + trimmed := strings.TrimSpace(path) + if len(trimmed) < 2 { + return false + } + drive := trimmed[0] + return ((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) && trimmed[1] == ':' +} + // hasBlockedWindowsPathPrefix 检查是否命中 Windows 底层设备路径前缀,避免绕过常规路径校验。 func hasBlockedWindowsPathPrefix(path string) bool { normalized := strings.ReplaceAll(strings.TrimSpace(path), "/", "\\") diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 66773148..37bad07b 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -59,7 +59,7 @@ type JSONRPCRequest struct { // JSONRPCResponse 表示控制面输出的 JSON-RPC 响应。 type JSONRPCResponse struct { JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id,omitempty"` + ID json.RawMessage `json:"id"` Result json.RawMessage `json:"result,omitempty"` Error *JSONRPCError `json:"error,omitempty"` } diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 2d9b37e0..9b586502 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -238,3 +238,22 @@ func TestJSONRPCHelpers(t *testing.T) { t.Fatal("unknown code should map to internal_error") } } + +func TestNewJSONRPCErrorResponseWithNilIDEncodesNull(t *testing.T) { + response := NewJSONRPCErrorResponse(nil, NewJSONRPCError(JSONRPCCodeParseError, "parse error", GatewayCodeInvalidFrame)) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal error response: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("unmarshal encoded response: %v", err) + } + if _, ok := payload["id"]; !ok { + t.Fatal("encoded response should contain id field") + } + if payload["id"] != nil { + t.Fatalf("encoded response id = %#v, want nil", payload["id"]) + } +} diff --git a/internal/gateway/server_additional_test.go b/internal/gateway/server_additional_test.go index 080b58e3..ecd1cd4f 100644 --- a/internal/gateway/server_additional_test.go +++ b/internal/gateway/server_additional_test.go @@ -401,6 +401,9 @@ func TestServerHandleConnectionInvalidJSONFrame(t *testing.T) { if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeInvalidFrame.String() { t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeInvalidFrame.String()) } + if got := strings.TrimSpace(string(response.ID)); got != "null" { + t.Fatalf("response id = %q, want %q", got, "null") + } _ = clientConn.Close() select { From 7a093a63cdd3f5a67ab1172b9bf2cade74b4b8c1 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 12:47:33 +0000 Subject: [PATCH 4/4] fix(gateway): harden jsonrpc invalid-id and frame error mapping Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/gateway/protocol/jsonrpc.go | 11 +++++---- internal/gateway/protocol/jsonrpc_test.go | 17 ++++++++++++++ internal/gateway/server.go | 2 +- internal/gateway/server_additional_test.go | 26 ++++++++++++++++++++++ internal/gateway/server_test.go | 4 ++-- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 37bad07b..eb6ad305 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -91,11 +91,11 @@ func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRP normalized := NormalizedRequest{} requestID, idErr := normalizeJSONRPCID(request.ID) - normalized.ID = cloneJSONRawMessage(request.ID) normalized.RequestID = requestID if idErr != nil { return normalized, idErr } + normalized.ID = cloneJSONRawMessage(request.ID) if strings.TrimSpace(request.JSONRPC) != JSONRPCVersion { return normalized, NewJSONRPCError( @@ -222,17 +222,17 @@ func normalizeJSONRPCID(id json.RawMessage) (string, *JSONRPCError) { ) } - switch typedID := decoded.(type) { + switch value := decoded.(type) { case string: - typedID = strings.TrimSpace(typedID) - if typedID == "" { + identifier := strings.TrimSpace(value) + if identifier == "" { return "", NewJSONRPCError( JSONRPCCodeInvalidRequest, "invalid field: id", GatewayCodeInvalidFrame, ) } - return typedID, nil + return identifier, nil case float64: identifier := strings.TrimSpace(string(trimmed)) if identifier == "" { @@ -242,7 +242,6 @@ func normalizeJSONRPCID(id json.RawMessage) (string, *JSONRPCError) { GatewayCodeInvalidFrame, ) } - _ = typedID return identifier, nil default: return "", NewJSONRPCError( diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 9b586502..e0cccc1f 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -186,6 +186,23 @@ func TestNormalizeJSONRPCRequestErrors(t *testing.T) { } } +func TestNormalizeJSONRPCRequestInvalidIDReturnsNullResponseID(t *testing.T) { + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`{}`), + Method: MethodGatewayPing, + }) + if rpcErr == nil { + t.Fatal("expected rpc error") + } + if rpcErr.Code != JSONRPCCodeInvalidRequest { + t.Fatalf("rpc code = %d, want %d", rpcErr.Code, JSONRPCCodeInvalidRequest) + } + if normalized.ID != nil { + t.Fatalf("normalized id = %s, want nil", string(normalized.ID)) + } +} + func TestJSONRPCHelpers(t *testing.T) { response, rpcErr := NewJSONRPCResultResponse(json.RawMessage(`"req-1"`), map[string]string{"message": "ok"}) if rpcErr != nil { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index ce3c902a..001e34fd 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -279,7 +279,7 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, runtimePor _ = s.writeRPCResponse(conn, encoder, protocol.NewJSONRPCErrorResponse( nil, protocol.NewJSONRPCError( - protocol.JSONRPCCodeInvalidParams, + protocol.JSONRPCCodeInvalidRequest, fmt.Sprintf("frame exceeds max size %d bytes", MaxFrameSize), protocol.GatewayCodeInvalidFrame, ), diff --git a/internal/gateway/server_additional_test.go b/internal/gateway/server_additional_test.go index ecd1cd4f..52466de9 100644 --- a/internal/gateway/server_additional_test.go +++ b/internal/gateway/server_additional_test.go @@ -308,6 +308,32 @@ func TestDispatchRPCRequestNormalizeError(t *testing.T) { } } +func TestDispatchRPCRequestInvalidIDReturnsNullID(t *testing.T) { + server := &Server{} + response := server.dispatchRPCRequest(context.Background(), protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`{"bad":"id"}`), + Method: protocol.MethodGatewayPing, + }, nil) + if response.Error == nil { + t.Fatal("expected rpc normalize error") + } + if response.Error.Code != protocol.JSONRPCCodeInvalidRequest { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidRequest) + } + encoded, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if id, ok := payload["id"]; !ok || id != nil { + t.Fatalf("encoded response id = %#v, want nil", payload["id"]) + } +} + func TestDispatchRPCRequestConvertsFrameErrorWithoutPayload(t *testing.T) { server := &Server{} originalHandlers := requestFrameHandlers diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go index a65817d2..69c3b154 100644 --- a/internal/gateway/server_test.go +++ b/internal/gateway/server_test.go @@ -155,8 +155,8 @@ func TestServerHandleConnectionRejectsOversizedFrame(t *testing.T) { if response.Error == nil { t.Fatal("response rpc error is nil") } - if response.Error.Code != protocol.JSONRPCCodeInvalidParams { - t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidParams) + if response.Error.Code != protocol.JSONRPCCodeInvalidRequest { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidRequest) } if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeInvalidFrame.String() { t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeInvalidFrame.String())