Skip to content

Commit c1b8944

Browse files
committed
feat(sdk): provider 侧入站 payload 校验——Go/JS/Python 对齐(矩阵缺口补齐)
- ClientConfig 新增 validateInputPayloads(默认关,行为兼容) - 按函数声明 input schema 校验入站 invoke/startTask payload, 失败回 {"error":"payload validation failed: …"},handler 不被调用; 非法 schema/payload JSON 跳过(服务端仍是权威校验方) - Go:Draft7 编译(santhosh-tekuri,与 invoker 同源);JS:Ajv 编译缓存;Python:jsonschema - 矩阵更新:Go/Python/JS ✅;新增 todo F15 章节 - 单测:Go 8 + JS 4 + Python 6
1 parent 8601a83 commit c1b8944

9 files changed

Lines changed: 457 additions & 10 deletions

File tree

sdks/SDK_FEATURE_MATRIX.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@
4343

4444
### L2 Provider 扩展
4545

46-
| 能力 | Go | Python | Java | JS/TS | C++ | C# |
47-
| ---------------------------------------------------------------------- | ------------------- | --------------------------- | ---- | -------------------- | --- | ------------------------ |
48-
| Descriptor v2 字段(builder/构造器) | | || |||
49-
| 呈现 hints 便捷层(`SetFieldHint`/`SetFieldWidget` 等价,x-ui-* 契约) | ✅ builder 方法 |`set_field_hint()` ||`setFieldHint()` |||
50-
| OpenAPI 注册 helper(`RegisterFromOpenAPI` 等价) | | || |||
51-
| JSON Schema 入站 payload 校验 | ❌ 依赖已声明未接线 | ❌ jsonschema 仅 Invoker 侧 | | ❌ Ajv 仅 Invoker 侧 ||`JsonSchemaValidator` |
52-
| 控制面 manifest 上传(`control_addr``RegisterCapabilitiesRequest`| | || |||
53-
| 文件传输(`enable_file_transfer`| | || |||
46+
| 能力 | Go | Python | Java | JS/TS | C++ | C# |
47+
| ---------------------------------------------------------------------- | --------------- | --------------------- | ---- | ------------------- | --- | ------------------------ |
48+
| Descriptor v2 字段(builder/构造器) |||||||
49+
| 呈现 hints 便捷层(`SetFieldHint`/`SetFieldWidget` 等价,x-ui-* 契约) | ✅ builder 方法 |`set_field_hint()` ||`setFieldHint()` |||
50+
| OpenAPI 注册 helper(`RegisterFromOpenAPI` 等价) |||||||
51+
| JSON Schema 入站 payload 校验(provider 侧,`validateInputPayloads`| | || ||`JsonSchemaValidator` |
52+
| 控制面 manifest 上传(`control_addr``RegisterCapabilitiesRequest`|||||||
53+
| 文件传输(`enable_file_transfer`|||||||
5454

5555
### L3 Invoker(invoke / startTask / getTaskStatus / streamTask / cancelTask)
5656

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package croupier
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
sdkv1 "github.com/cuihairu/croupier/sdks/go/pkg/pb/croupier/sdk/v1"
8+
)
9+
10+
// F15:provider 侧入站 payload 校验(按函数声明的 input schema)。
11+
func TestTCPManager_ValidateInboundPayload(t *testing.T) {
12+
newManager := func(validate bool) *TCPManager {
13+
config := ClientConfig{ValidateInputPayloads: validate}
14+
manager, err := NewTCPManager(config, map[string]FunctionHandler{})
15+
if err != nil {
16+
t.Fatalf("NewTCPManager: %v", err)
17+
}
18+
tm := manager.(*TCPManager)
19+
tm.functions = []*sdkv1.ProviderFunctionDescriptor{
20+
{
21+
Id: "player.ban",
22+
InputSchema: `{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}`,
23+
},
24+
{
25+
Id: "player.free",
26+
InputSchema: ``,
27+
},
28+
{
29+
Id: "player.broken",
30+
InputSchema: `not-json`,
31+
},
32+
}
33+
return tm
34+
}
35+
36+
t.Run("disabled flag skips validation", func(t *testing.T) {
37+
m := newManager(false)
38+
if err := m.validateInboundPayload("player.ban", []byte(`{}`)); err != nil {
39+
t.Fatalf("expected skip when disabled, got %v", err)
40+
}
41+
})
42+
43+
t.Run("valid payload passes", func(t *testing.T) {
44+
m := newManager(true)
45+
if err := m.validateInboundPayload("player.ban", []byte(`{"id":"p1"}`)); err != nil {
46+
t.Fatalf("expected pass, got %v", err)
47+
}
48+
})
49+
50+
t.Run("missing required rejected", func(t *testing.T) {
51+
m := newManager(true)
52+
err := m.validateInboundPayload("player.ban", []byte(`{}`))
53+
if err == nil || !strings.Contains(err.Error(), "payload validation failed") {
54+
t.Fatalf("expected validation failure, got %v", err)
55+
}
56+
})
57+
58+
t.Run("type mismatch rejected", func(t *testing.T) {
59+
m := newManager(true)
60+
err := m.validateInboundPayload("player.ban", []byte(`{"id":123}`))
61+
if err == nil || !strings.Contains(err.Error(), "payload validation failed") {
62+
t.Fatalf("expected validation failure, got %v", err)
63+
}
64+
})
65+
66+
t.Run("invalid payload json rejected", func(t *testing.T) {
67+
m := newManager(true)
68+
err := m.validateInboundPayload("player.ban", []byte(`not-json`))
69+
if err == nil || !strings.Contains(err.Error(), "payload must be valid JSON") {
70+
t.Fatalf("expected json error, got %v", err)
71+
}
72+
})
73+
74+
t.Run("unknown function skips", func(t *testing.T) {
75+
m := newManager(true)
76+
if err := m.validateInboundPayload("ghost", []byte(`{}`)); err != nil {
77+
t.Fatalf("expected skip for unknown function, got %v", err)
78+
}
79+
})
80+
81+
t.Run("empty schema skips", func(t *testing.T) {
82+
m := newManager(true)
83+
if err := m.validateInboundPayload("player.free", []byte(`{}`)); err != nil {
84+
t.Fatalf("expected skip for empty schema, got %v", err)
85+
}
86+
})
87+
88+
t.Run("broken schema skips", func(t *testing.T) {
89+
m := newManager(true)
90+
if err := m.validateInboundPayload("player.broken", []byte(`{}`)); err != nil {
91+
t.Fatalf("expected skip for broken schema, got %v", err)
92+
}
93+
})
94+
}

sdks/go/pkg/croupier/tcp_manager.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package croupier
33

44
import (
55
"context"
6+
"encoding/json"
67
"fmt"
8+
"strings"
79
"sync"
810
"sync/atomic"
911
"time"
1012

1113
agentv1 "github.com/cuihairu/croupier/sdks/go/pkg/pb/croupier/agent/v1"
1214
sdkv1 "github.com/cuihairu/croupier/sdks/go/pkg/pb/croupier/sdk/v1"
15+
"github.com/santhosh-tekuri/jsonschema/v6"
1316
"google.golang.org/protobuf/proto"
1417

1518
"github.com/cuihairu/croupier/sdks/go/pkg/croupier/protocol"
@@ -496,6 +499,16 @@ func (h *tcpRPCHandler) invoke(ctx context.Context, msgID uint32, reqID uint32,
496499
return nil, fmt.Errorf("function not found: %s", req.FunctionId)
497500
}
498501

502+
// 入站 payload 校验(按函数声明的 input schema):失败回错误 payload,
503+
// 游戏逻辑不会看到非法输入(服务端仍是权威校验方)。
504+
if err := h.manager.validateInboundPayload(req.FunctionId, req.Payload); err != nil {
505+
errResp := &sdkv1.InvokeResponse{Payload: []byte(`{"error":` + jsonString(err.Error()) + `}`)}
506+
if b, marshalErr := proto.Marshal(errResp); marshalErr == nil {
507+
return b, nil
508+
}
509+
return nil, err
510+
}
511+
499512
// OTel 一期传播:metadata trace 字段进 handler 上下文(零侵入,无则原 ctx)
500513
ctx = WithTraceMetadata(ctx, req.GetMetadata())
501514
result, err := handler(ctx, req.Payload)
@@ -509,6 +522,62 @@ func (h *tcpRPCHandler) invoke(ctx context.Context, msgID uint32, reqID uint32,
509522
return proto.Marshal(resp)
510523
}
511524

525+
// jsonString 序列化为 JSON 字符串字面量(含引号),用于拼接错误 payload。
526+
func jsonString(s string) string {
527+
b, err := json.Marshal(s)
528+
if err != nil {
529+
return `""`
530+
}
531+
return string(b)
532+
}
533+
534+
// validateInboundPayload 按 ProviderFunctionDescriptor 声明的 input schema
535+
// 校验入站 payload。未开启开关 / 未找到描述符 / schema 为空 / schema 或
536+
// payload 非法 JSON 时跳过(服务端仍是权威校验方);编译结果按函数缓存。
537+
func (m *TCPManager) validateInboundPayload(functionID string, payload []byte) error {
538+
if !m.config.ValidateInputPayloads {
539+
return nil
540+
}
541+
m.mu.RLock()
542+
var schemaRaw string
543+
for _, descriptor := range m.functions {
544+
if descriptor != nil && descriptor.Id == functionID {
545+
schemaRaw = descriptor.InputSchema
546+
break
547+
}
548+
}
549+
m.mu.RUnlock()
550+
if strings.TrimSpace(schemaRaw) == "" {
551+
return nil
552+
}
553+
554+
var schemaDoc interface{}
555+
if err := json.Unmarshal([]byte(schemaRaw), &schemaDoc); err != nil {
556+
// 非法 schema 不在 provider 侧报错(注册校验负责),跳过
557+
return nil
558+
}
559+
560+
var value interface{}
561+
if err := json.Unmarshal(payload, &value); err != nil {
562+
return fmt.Errorf("payload must be valid JSON: %w", err)
563+
}
564+
565+
compiler := jsonschema.NewCompiler()
566+
compiler.DefaultDraft(jsonschema.Draft7)
567+
if err := compiler.AddResource("schema.json", schemaDoc); err != nil {
568+
// 编译失败视为 schema 缺陷,跳过(与非法 schema 同策略)
569+
return nil
570+
}
571+
sch, err := compiler.Compile("schema.json")
572+
if err != nil {
573+
return nil
574+
}
575+
if err := sch.Validate(value); err != nil {
576+
return fmt.Errorf("payload validation failed: %s", err.Error())
577+
}
578+
return nil
579+
}
580+
512581
func (h *tcpRPCHandler) startTask(ctx context.Context, msgID uint32, reqID uint32, body []byte) (respBody []byte, err error) {
513582
req := &sdkv1.InvokeRequest{}
514583
if err := proto.Unmarshal(body, req); err != nil {
@@ -523,6 +592,11 @@ func (h *tcpRPCHandler) startTask(ctx context.Context, msgID uint32, reqID uint3
523592
return nil, fmt.Errorf("function not found: %s", req.FunctionId)
524593
}
525594

595+
// 入站 payload 校验(同 invoke):失败回错误给 agent,任务不启动。
596+
if err := h.manager.validateInboundPayload(req.FunctionId, req.Payload); err != nil {
597+
return nil, err
598+
}
599+
526600
// OTel 一期传播:任务 handler 上下文同样可读 trace 字段
527601
taskCtx, cancel := context.WithCancel(WithTraceMetadata(ctx, req.GetMetadata()))
528602

sdks/go/pkg/croupier/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ type ClientConfig struct {
137137
// File transfer settings
138138
EnableFileTransfer bool `json:"enableFileTransfer"` // enable file transfer
139139
MaxFileSize int `json:"maxFileSize"` // max file size in bytes
140+
141+
// Inbound payload validation (F15): when true, provider-side dispatch
142+
// validates incoming invoke/task payloads against the function's declared
143+
// input schema before invoking the handler. Server remains authoritative.
144+
ValidateInputPayloads bool `json:"validateInputPayloads"`
140145
}
141146

142147
// generateUUID generates a random UUID-like string using crypto/rand
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* F15 验收测试:provider 侧入站 payload 校验
3+
*/
4+
import { Buffer } from "node:buffer";
5+
import protobuf from "protobufjs";
6+
import { BasicClient, type ClientConfig, type FunctionDescriptor } from "./index";
7+
8+
const proto = `
9+
syntax = "proto3";
10+
package croupier.sdk.v1;
11+
message InvokeRequest {
12+
string function_id = 1;
13+
string idempotencyKey = 2;
14+
bytes payload = 3;
15+
map<string, string> metadata = 4;
16+
}
17+
message InvokeResponse {
18+
bytes payload = 1;
19+
}
20+
`;
21+
const root = protobuf.parse(proto).root;
22+
const InvokeRequestMessage = root.lookupType("croupier.sdk.v1.InvokeRequest");
23+
const InvokeResponseMessage = root.lookupType("croupier.sdk.v1.InvokeResponse");
24+
25+
function encodeInvoke(functionId: string, payload: string): Buffer {
26+
return Buffer.from(
27+
InvokeRequestMessage.encode(
28+
InvokeRequestMessage.create({ functionId, payload: Buffer.from(payload) }),
29+
).finish(),
30+
);
31+
}
32+
33+
function makeClient(validate: boolean, handlerPayload?: string): BasicClient {
34+
const config: ClientConfig = {
35+
autoReconnect: false,
36+
validateInputPayloads: validate,
37+
};
38+
const client = new BasicClient(config);
39+
const descriptor: FunctionDescriptor = {
40+
id: "player.ban",
41+
version: "1.0.0",
42+
inputSchema: {
43+
type: "object",
44+
properties: { id: { type: "string" } },
45+
required: ["id"],
46+
},
47+
};
48+
client.registerFunction(descriptor, () => handlerPayload ?? "ok");
49+
return client;
50+
}
51+
52+
function invoke(client: BasicClient, functionId: string, payload: string): Promise<string> {
53+
const anyClient = client as unknown as {
54+
handleInboundInvoke: (body: Buffer) => Promise<Buffer>;
55+
};
56+
return anyClient.handleInboundInvoke(encodeInvoke(functionId, payload)).then((response) => {
57+
const decoded = InvokeResponseMessage.decode(response) as { payload?: Uint8Array };
58+
return new TextDecoder().decode(decoded.payload ?? new Uint8Array());
59+
});
60+
}
61+
62+
describe("F15: 入站 payload 校验", () => {
63+
test("合法 payload 通过并调用 handler", async () => {
64+
const client = makeClient(true);
65+
const response = await invoke(client, "player.ban", JSON.stringify({ id: "p1" }));
66+
expect(response).toBe("ok");
67+
});
68+
69+
test("缺 required 字段回错误 payload,handler 不被调用", async () => {
70+
const handler = jest.fn(() => "ok");
71+
const client = new BasicClient({ autoReconnect: false, validateInputPayloads: true });
72+
client.registerFunction(
73+
{
74+
id: "player.ban",
75+
version: "1.0.0",
76+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
77+
},
78+
handler,
79+
);
80+
const response = await invoke(client, "player.ban", "{}");
81+
const parsed = JSON.parse(response) as { error?: string };
82+
expect(parsed.error).toContain("payload validation failed");
83+
expect(handler).not.toHaveBeenCalled();
84+
});
85+
86+
test("类型不符回错误 payload", async () => {
87+
const client = makeClient(true);
88+
const response = await invoke(client, "player.ban", JSON.stringify({ id: 123 }));
89+
const parsed = JSON.parse(response) as { error?: string };
90+
expect(parsed.error).toContain("payload validation failed");
91+
});
92+
93+
test("开关关闭跳过校验(兼容旧行为)", async () => {
94+
const handler = jest.fn(() => "ok");
95+
const client = new BasicClient({ autoReconnect: false });
96+
client.registerFunction(
97+
{
98+
id: "player.ban",
99+
version: "1.0.0",
100+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
101+
},
102+
handler,
103+
);
104+
const response = await invoke(client, "player.ban", "{}");
105+
expect(response).toBe("ok");
106+
expect(handler).toHaveBeenCalled();
107+
});
108+
});

0 commit comments

Comments
 (0)