toolsrv: fix multiline content truncation in tool args
Bug: spec9p.go was double-processing payloads - parseKV unescaped \n to newlines, then rebuilt the payload without re-escaping, then fs9p.go's parsePayload tried to unescape again. Result: multiline content like file contents was truncated to first line. Fix: Pass raw payload data through to fs9p.go. Escaping/unescaping happens once in parsePayload. Also: - Add proper one-pass unescape function (handles \\n correctly) - Add TestIntegration_MultilineContent to verify fix - Add InputSchema verification to TestIntegration_ToolExecution
This commit is contained in:
parent
89493c32da
commit
b01189e731
|
|
@ -131,7 +131,8 @@ func (c *Conn) CallTool(ctx context.Context, name string, args json.RawMessage)
|
||||||
|
|
||||||
payload := fmt.Sprintf("token=%s\ntool=%s\n", c.token, name)
|
payload := fmt.Sprintf("token=%s\ntool=%s\n", c.token, name)
|
||||||
for k, v := range argMap {
|
for k, v := range argMap {
|
||||||
payload += fmt.Sprintf("%s=%v\n", k, escapeValue(fmt.Sprintf("%v", v)))
|
escaped := escapeValue(fmt.Sprintf("%v", v))
|
||||||
|
payload += fmt.Sprintf("%s=%s\n", k, escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := fid.Write([]byte(payload)); err != nil {
|
if _, err := fid.Write([]byte(payload)); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -401,14 +401,34 @@ func parsePayload(payload string) map[string]string {
|
||||||
}
|
}
|
||||||
key := line[:idx]
|
key := line[:idx]
|
||||||
value := line[idx+1:]
|
value := line[idx+1:]
|
||||||
// Unescape \n and \\
|
// Unescape \n and \\ in one pass to handle \\n correctly
|
||||||
value = strings.ReplaceAll(value, "\\n", "\n")
|
value = unescapeValue(value)
|
||||||
value = strings.ReplaceAll(value, "\\\\", "\\")
|
|
||||||
args[key] = value
|
args[key] = value
|
||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unescapeValue reverses escapeValue: \\n -> newline, \\\\ -> backslash
|
||||||
|
func unescapeValue(s string) string {
|
||||||
|
result := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\\' && i+1 < len(s) {
|
||||||
|
switch s[i+1] {
|
||||||
|
case 'n':
|
||||||
|
result = append(result, '\n')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
case '\\':
|
||||||
|
result = append(result, '\\')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, s[i])
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
// argsToJSON converts the args map to JSON for the existing tool path.
|
// argsToJSON converts the args map to JSON for the existing tool path.
|
||||||
func argsToJSON(args map[string]string) []byte {
|
func argsToJSON(args map[string]string) []byte {
|
||||||
// Build JSON manually to avoid import cycle
|
// Build JSON manually to avoid import cycle
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,59 @@ func TestIntegration_ToolExecution(t *testing.T) {
|
||||||
t.Logf("Tool result: %+v", toolResult)
|
t.Logf("Tool result: %+v", toolResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegration_MultilineContent(t *testing.T) {
|
||||||
|
// Skip if tools directory doesn't exist (CI environment)
|
||||||
|
if _, err := os.ReadDir(ToolsPath()); err != nil {
|
||||||
|
t.Skipf("skipping: tools directory not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
socketPath, cleanup := startTestServer(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
conn, err := Dial(socketPath, "test-secret-multiline")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial failed: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Load the file_write tool
|
||||||
|
if err := conn.LoadTool("file_write"); err != nil {
|
||||||
|
t.Skipf("skipping: LoadTool failed (file_write tool not available): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temp file path
|
||||||
|
tmpFile := filepath.Join(t.TempDir(), "multiline_test.txt")
|
||||||
|
|
||||||
|
// Test with multiline content including special characters
|
||||||
|
multilineContent := "line one\nline two\nline three with \"quotes\"\nline four with 'single quotes'\nline five with \\backslash\n"
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
args := json.RawMessage(fmt.Sprintf(`{"path": %q, "content": %q}`, tmpFile, multilineContent))
|
||||||
|
result, err := conn.CallTool(ctx, "file_write", args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallTool failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolResult ToolResult
|
||||||
|
if err := json.Unmarshal(result, &toolResult); err != nil {
|
||||||
|
t.Fatalf("failed to parse ToolResult: %v (raw: %s)", err, string(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolResult.IsError {
|
||||||
|
t.Fatalf("file_write returned error: %s", toolResult.Content[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file back and verify
|
||||||
|
written, err := os.ReadFile(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read written file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(written) != multilineContent {
|
||||||
|
t.Errorf("content mismatch:\nexpected: %q\ngot: %q", multilineContent, string(written))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegration_ToolExecutionError(t *testing.T) {
|
func TestIntegration_ToolExecutionError(t *testing.T) {
|
||||||
// Skip if tools directory doesn't exist (CI environment)
|
// Skip if tools directory doesn't exist (CI environment)
|
||||||
if _, err := os.ReadDir(ToolsPath()); err != nil {
|
if _, err := os.ReadDir(ToolsPath()); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -102,21 +102,9 @@ func handleInfoRead(ctx ToolsrvCtx) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleProcNew(ctx ToolsrvCtx, data []byte) ([]byte, error) {
|
func handleProcNew(ctx ToolsrvCtx, data []byte) ([]byte, error) {
|
||||||
args := parseKV(string(data))
|
// Pass raw payload through - fs9p.go's parsePayload will handle escaping.
|
||||||
|
// Token verification is done at connection level via Tauth.
|
||||||
// Token verification is now done at connection level via Tauth.
|
result, err := ctx.Server.fs.HandleProcNew(context.Background(), string(data))
|
||||||
// Just pass through to the FS handler.
|
|
||||||
delete(args, "token") // remove token from payload if present
|
|
||||||
|
|
||||||
var payload strings.Builder
|
|
||||||
for k, v := range args {
|
|
||||||
payload.WriteString(k)
|
|
||||||
payload.WriteString("=")
|
|
||||||
payload.WriteString(v)
|
|
||||||
payload.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ctx.Server.fs.HandleProcNew(context.Background(), payload.String())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -124,20 +112,9 @@ func handleProcNew(ctx ToolsrvCtx, data []byte) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleProcNewBg(ctx ToolsrvCtx, data []byte) error {
|
func handleProcNewBg(ctx ToolsrvCtx, data []byte) error {
|
||||||
args := parseKV(string(data))
|
// Pass raw payload through - fs9p.go's parsePayload will handle escaping.
|
||||||
|
// Token verification is done at connection level via Tauth.
|
||||||
// Token verification is now done at connection level via Tauth.
|
_, err := ctx.Server.fs.HandleProcNewBg(context.Background(), string(data))
|
||||||
delete(args, "token") // remove token from payload if present
|
|
||||||
|
|
||||||
var payload strings.Builder
|
|
||||||
for k, v := range args {
|
|
||||||
payload.WriteString(k)
|
|
||||||
payload.WriteString("=")
|
|
||||||
payload.WriteString(v)
|
|
||||||
payload.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ctx.Server.fs.HandleProcNewBg(context.Background(), payload.String())
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,10 +191,30 @@ func parseKV(input string) map[string]string {
|
||||||
}
|
}
|
||||||
k := line[:idx]
|
k := line[:idx]
|
||||||
v := line[idx+1:]
|
v := line[idx+1:]
|
||||||
// Unescape \n and \\
|
// Unescape \n and \\ in one pass
|
||||||
v = strings.ReplaceAll(v, "\\n", "\n")
|
v = unescapeKV(v)
|
||||||
v = strings.ReplaceAll(v, "\\\\", "\\")
|
|
||||||
m[k] = v
|
m[k] = v
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unescapeKV reverses escape sequences: \n -> newline, \\ -> backslash
|
||||||
|
func unescapeKV(s string) string {
|
||||||
|
result := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\\' && i+1 < len(s) {
|
||||||
|
switch s[i+1] {
|
||||||
|
case 'n':
|
||||||
|
result = append(result, '\n')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
case '\\':
|
||||||
|
result = append(result, '\\')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, s[i])
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue