diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 64cc1cb7..398cbd77 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -240,13 +240,25 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - lines[i] = restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) + restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) + if errRestore != nil { + errRestore = fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore) + helps.RecordAPIResponseError(ctx, e.cfg, errRestore) + return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errRestore) + } + lines[i] = restoredLine } data = bytes.Join(lines, []byte("\n")) } else { commitClaudeDiagnostics(diagnosticsState, claudeMessageIDFromResponse(data)) reporter.Publish(ctx, helps.ParseClaudeUsage(data)) - data = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) + var errRestore error + data, errRestore = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) + if errRestore != nil { + errRestore = fmt.Errorf("restore Claude OAuth tool name from response: %w", errRestore) + helps.RecordAPIResponseError(ctx, e.cfg, errRestore) + return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errRestore) + } } data = e.restoreResponseModel(data, req.Model) cacheClaudeThinkingReplayResponse(ctx, replayScope, data) diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index beaf736e..a391ecb2 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -916,11 +916,11 @@ func prepareClaudeOAuthToolNamesForUpstream(body []byte, mcpAliases claudeMCPAli return remapOAuthToolNamesWithOptions(body, mcpAliases) } -func restoreClaudeOAuthToolNamesFromResponse(body []byte, reverseMap map[string]string) []byte { +func restoreClaudeOAuthToolNamesFromResponse(body []byte, reverseMap map[string]string) ([]byte, error) { return reverseRemapOAuthToolNames(body, reverseMap) } -func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte { +func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) ([]byte, error) { return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) } @@ -1397,29 +1397,164 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias return body, reverseMap } +type claudeMCPAliasParts struct { + server string + toolID string + semantic string +} + +type claudeMCPAliasEntry struct { + alias string + original string + parts claudeMCPAliasParts +} + +type claudeMCPAliasResolver struct { + exact map[string]string + aliases []claudeMCPAliasEntry + servers map[string]struct{} +} + +func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResolver { + resolver := claudeMCPAliasResolver{ + exact: reverseMap, + aliases: make([]claudeMCPAliasEntry, 0, len(reverseMap)), + servers: make(map[string]struct{}), + } + for alias, original := range reverseMap { + parts, ok := parseClaudeMCPAlias(alias) + if !ok { + continue + } + resolver.aliases = append(resolver.aliases, claudeMCPAliasEntry{ + alias: alias, + original: original, + parts: parts, + }) + resolver.servers[parts.server] = struct{}{} + } + return resolver +} + +func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) { + rest, ok := strings.CutPrefix(name, "mcp__") + if !ok { + return claudeMCPAliasParts{}, false + } + server, tool, ok := strings.Cut(rest, "__") + if !ok || !isClaudeMCPAliasDigest(server) { + return claudeMCPAliasParts{}, false + } + toolID, semantic, ok := strings.Cut(tool, "_") + if !ok || !isClaudeMCPAliasDigest(toolID) || semantic == "" { + return claudeMCPAliasParts{}, false + } + return claudeMCPAliasParts{server: server, toolID: toolID, semantic: semantic}, true +} + +func isClaudeMCPAliasDigest(value string) bool { + if len(value) != 12 { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < '2' || char > '7') { + return false + } + } + return true +} + +func claudeMCPAliasServer(name string) string { + rest, ok := strings.CutPrefix(name, "mcp__") + if !ok { + return "" + } + server, _, ok := strings.Cut(rest, "__") + if !ok { + return "" + } + return server +} + +func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error) { + if original, ok := resolver.exact[name]; ok { + return original, true, nil + } + + server := claudeMCPAliasServer(name) + if _, known := resolver.servers[server]; !known { + return "", false, nil + } + + matchedOriginal := "" + matchCount := 0 + for _, entry := range resolver.aliases { + if entry.parts.server == server && strings.HasSuffix(name, entry.alias) { + matchedOriginal = entry.original + matchCount++ + } + } + if matchCount == 1 { + return matchedOriginal, true, nil + } + if matchCount > 1 { + return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: matched multiple declared aliases", name) + } + + parts, ok := parseClaudeMCPAlias(name) + if ok { + for _, entry := range resolver.aliases { + if entry.parts.server == parts.server && entry.parts.semantic == parts.semantic { + matchedOriginal = entry.original + matchCount++ + } + } + if matchCount == 1 { + return matchedOriginal, true, nil + } + if matchCount > 1 { + return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: semantic suffix matches multiple declared tools", name) + } + } + + return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: no unique request-local match", name) +} + // reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses -// using the per-request map produced by remapOAuthToolNames. Names the client sent -// that were NOT forward-renamed are passed through unchanged. -func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byte { +// using the per-request map produced by remapOAuthToolNames. Names outside the +// request-local generated MCP server are passed through unchanged. +func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]byte, error) { if len(reverseMap) == 0 { - return body + return body, nil } content := gjson.GetBytes(body, "content") if !content.Exists() || !content.IsArray() { - return body + return body, nil } + resolver := newClaudeMCPAliasResolver(reverseMap) + var resolveErr error content.ForEach(func(index, part gjson.Result) bool { partType := part.Get("type").String() switch partType { case "tool_use": name := part.Get("name").String() - if origName, ok := reverseMap[name]; ok { + origName, matched, errResolve := resolver.resolve(name) + if errResolve != nil { + resolveErr = errResolve + return false + } + if matched { path := fmt.Sprintf("content.%d.name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } case "tool_reference": toolName := part.Get("tool_name").String() - if origName, ok := reverseMap[toolName]; ok { + origName, matched, errResolve := resolver.resolve(toolName) + if errResolve != nil { + resolveErr = errResolve + return false + } + if matched { path := fmt.Sprintf("content.%d.tool_name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } @@ -1431,7 +1566,12 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byt return true } toolName := nestedPart.Get("tool_name").String() - if origName, ok := reverseMap[toolName]; ok { + origName, matched, errResolve := resolver.resolve(toolName) + if errResolve != nil { + resolveErr = errResolve + return false + } + if matched { path := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, path, origName) } @@ -1439,27 +1579,28 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byt }) } } - return true + return resolveErr == nil }) - return body + return body, resolveErr } // reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE // stream lines, using the per-request reverseMap produced by remapOAuthToolNames. -func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte { +func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) ([]byte, error) { if len(reverseMap) == 0 { - return line + return line, nil } payload := helps.JSONPayload(line) if len(payload) == 0 || !gjson.ValidBytes(payload) { - return line + return line, nil } contentBlock := gjson.GetBytes(payload, "content_block") if !contentBlock.Exists() { - return line + return line, nil } + resolver := newClaudeMCPAliasResolver(reverseMap) blockType := contentBlock.Get("type").String() var updated []byte var err error @@ -1467,33 +1608,36 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string switch blockType { case "tool_use": name := contentBlock.Get("name").String() - if origName, ok := reverseMap[name]; ok { - updated, err = sjson.SetBytes(payload, "content_block.name", origName) - if err != nil { - return line - } - } else { - return line + origName, matched, errResolve := resolver.resolve(name) + if errResolve != nil { + return line, errResolve + } + if !matched { + return line, nil } + updated, err = sjson.SetBytes(payload, "content_block.name", origName) case "tool_reference": toolName := contentBlock.Get("tool_name").String() - if origName, ok := reverseMap[toolName]; ok { - updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName) - if err != nil { - return line - } - } else { - return line + origName, matched, errResolve := resolver.resolve(toolName) + if errResolve != nil { + return line, errResolve } + if !matched { + return line, nil + } + updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName) default: - return line + return line, nil + } + if err != nil { + return line, fmt.Errorf("rewrite Claude OAuth MCP tool alias: %w", err) } trimmed := bytes.TrimSpace(line) if bytes.HasPrefix(trimmed, []byte("data:")) { - return append([]byte("data: "), updated...) + return append([]byte("data: "), updated...), nil } - return updated + return updated, nil } func applyClaudeToolPrefix(body []byte, prefix string) []byte { diff --git a/internal/runtime/executor/claude_executor_request_remap_test.go b/internal/runtime/executor/claude_executor_request_remap_test.go index 0488ce9d..ef77c9bd 100644 --- a/internal/runtime/executor/claude_executor_request_remap_test.go +++ b/internal/runtime/executor/claude_executor_request_remap_test.go @@ -94,6 +94,141 @@ func TestRemapOAuthToolNamesWithOptionsFallsBackForMalformedJSON(t *testing.T) { } } +func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) { + body := []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}},{"name":"read","input_schema":{"type":"object"}}]}`) + remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "mangled-alias-caller"}) + globAlias := gjson.GetBytes(remapped, "tools.0.name").String() + readAlias := gjson.GetBytes(remapped, "tools.1.name").String() + globParts, ok := parseClaudeMCPAlias(globAlias) + if !ok { + t.Fatalf("glob alias is invalid: %q", globAlias) + } + readParts, ok := parseClaudeMCPAlias(readAlias) + if !ok { + t.Fatalf("read alias is invalid: %q", readAlias) + } + + repeatedAlias := "mcp__" + globParts.server + "__" + globAlias + mixedAlias := "mcp__" + globParts.server + "__" + globParts.toolID + "_" + readParts.semantic + response := []byte(fmt.Sprintf(`{"content":[ + {"type":"tool_use","id":"toolu_glob","name":%q,"input":{}}, + {"type":"tool_reference","tool_name":%q}, + {"type":"tool_result","tool_use_id":"toolu_read","content":[{"type":"tool_reference","tool_name":%q}]} + ]}`, repeatedAlias, mixedAlias, mixedAlias)) + + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } + if got := gjson.GetBytes(restored, "content.0.name").String(); got != "glob" { + t.Fatalf("repeated alias restored to %q, want glob", got) + } + if got := gjson.GetBytes(restored, "content.1.tool_name").String(); got != "read" { + t.Fatalf("mixed alias restored to %q, want read", got) + } + if got := gjson.GetBytes(restored, "content.2.content.0.tool_name").String(); got != "read" { + t.Fatalf("nested mixed alias restored to %q, want read", got) + } + + streamTests := []struct { + name string + block string + fieldPath string + want string + }{ + { + name: "repeated tool use alias", + block: fmt.Sprintf(`{"type":"tool_use","id":"toolu_glob","name":%q,"input":{}}`, repeatedAlias), + fieldPath: "content_block.name", + want: "glob", + }, + { + name: "mixed tool reference alias", + block: fmt.Sprintf(`{"type":"tool_reference","tool_name":%q}`, mixedAlias), + fieldPath: "content_block.tool_name", + want: "read", + }, + } + for _, test := range streamTests { + t.Run(test.name, func(t *testing.T) { + line := []byte(`data: {"type":"content_block_start","index":0,"content_block":` + test.block + `}`) + restoredLine, errStream := reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) + if errStream != nil { + t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream) + } + if got := gjson.GetBytes(helps.JSONPayload(restoredLine), test.fieldPath).String(); got != test.want { + t.Fatalf("restored stream name = %q, want %q", got, test.want) + } + }) + } +} + +func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) { + body := []byte(`{"tools":[{"name":"tool.name"},{"name":"tool/name"}]}`) + remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "ambiguous-alias-caller"}) + firstAlias := gjson.GetBytes(remapped, "tools.0.name").String() + secondAlias := gjson.GetBytes(remapped, "tools.1.name").String() + firstParts, ok := parseClaudeMCPAlias(firstAlias) + if !ok { + t.Fatalf("first alias is invalid: %q", firstAlias) + } + secondParts, ok := parseClaudeMCPAlias(secondAlias) + if !ok { + t.Fatalf("second alias is invalid: %q", secondAlias) + } + if firstParts.semantic != secondParts.semantic { + t.Fatalf("semantic suffixes differ: %q != %q", firstParts.semantic, secondParts.semantic) + } + + unknownToolID := "aaaaaaaaaaaa" + if unknownToolID == firstParts.toolID || unknownToolID == secondParts.toolID { + unknownToolID = "bbbbbbbbbbbb" + } + tests := []struct { + name string + alias string + wantError string + }{ + { + name: "ambiguous semantic suffix", + alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_" + firstParts.semantic, + wantError: "semantic suffix matches multiple declared tools", + }, + { + name: "unrecoverable semantic suffix", + alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_missing_tool", + wantError: "no unique request-local match", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, test.alias)) + if _, errReverse := reverseRemapOAuthToolNames(response, reverseMap); errReverse == nil || !strings.Contains(errReverse.Error(), test.wantError) { + t.Fatalf("reverseRemapOAuthToolNames() error = %v, want %q", errReverse, test.wantError) + } + + line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}}`, test.alias)) + if _, errStream := reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap); errStream == nil || !strings.Contains(errStream.Error(), test.wantError) { + t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v, want %q", errStream, test.wantError) + } + }) + } +} + +func TestReverseRemapOAuthToolNamesPreservesUnrelatedMCPName(t *testing.T) { + body := []byte(`{"tools":[{"name":"glob"}]}`) + _, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "unrelated-mcp-caller"}) + response := []byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":"mcp__external__query","input":{}}]}`) + + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } + if got := gjson.GetBytes(restored, "content.0.name").String(); got != "mcp__external__query" { + t.Fatalf("unrelated MCP name = %q, want unchanged", got) + } +} + func TestApplyClaudeRawJSONEditsRejectsInvalidRanges(t *testing.T) { body := []byte(`{"a":"one","b":"two"}`) tests := []struct { diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 99cfaff9..08c42916 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -237,6 +237,15 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } return true } + emitResponseError := func(errResponse error) { + errResponse = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errResponse) + helps.RecordAPIResponseError(ctx, e.cfg, errResponse) + reporter.PublishFailure(ctx, errResponse) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errResponse}: + case <-ctx.Done(): + } + } // If the response target is Claude, directly forward complete SSE events without translation. if responseFormat == to { @@ -265,8 +274,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - line = restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - line = e.restoreResponseModel(line, req.Model) + restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) + if errRestore != nil { + emitResponseError(fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore)) + return + } + line = e.restoreResponseModel(restoredLine, req.Model) event.Write(line) event.WriteByte('\n') if len(bytes.TrimSpace(line)) == 0 && !flushEvent() { @@ -310,8 +323,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - line = restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - line = e.restoreResponseModel(line, req.Model) + restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) + if errRestore != nil { + emitResponseError(fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore)) + return + } + line = e.restoreResponseModel(restoredLine, req.Model) chunks := sdktranslator.TranslateStream( ctx, to, diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index b270aae4..2d69b7a6 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -4478,7 +4478,10 @@ func TestRemapOAuthToolNames_AllClientNamesUseMCPAliases(t *testing.T) { t.Fatalf("reverseMap = %v, want %q -> %q", reverseMap, alias, original) } resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":` + fmt.Sprintf("%q", alias) + `,"input":{}}]}`) - reversed := reverseRemapOAuthToolNames(resp, reverseMap) + reversed, errReverse := reverseRemapOAuthToolNames(resp, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } if got := gjson.GetBytes(reversed, "content.0.name").String(); got != original { t.Fatalf("content.0.name = %q, want %q", got, original) } @@ -4577,7 +4580,10 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) { {"type":"tool_reference","tool_name":%q}, {"type":"tool_result","tool_use_id":"toolu_unknown","content":[{"type":"tool_reference","tool_name":%q}]} ]}`, searchAlias, caseAlias, searchAlias)) - restored := reverseRemapOAuthToolNames(response, reverseMap) + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } if got := gjson.GetBytes(restored, "content.0.name").String(); got != "search_web" { t.Fatalf("restored tool_use.name = %q, want search_web", got) } @@ -4589,7 +4595,10 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) { } streamLine := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_unknown","name":%q,"input":{}}}`, searchAlias)) - restoredLine := reverseRemapOAuthToolNamesFromStreamLine(streamLine, reverseMap) + restoredLine, errReverse := reverseRemapOAuthToolNamesFromStreamLine(streamLine, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errReverse) + } if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "search_web" { t.Fatalf("restored stream name = %q, want search_web: %s", got, restoredLine) } @@ -4690,7 +4699,10 @@ func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) { t.Fatalf("semantic alias is not stable across requests: %q != %q", got, alias) } response := []byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":` + fmt.Sprintf("%q", alias) + `,"input":{}}]}`) - restored := reverseRemapOAuthToolNames(response, reverseMap) + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } if got := gjson.GetBytes(restored, "content.0.name").String(); got != original { t.Fatalf("restored tool name = %q, want exact original %q", got, original) } @@ -4768,7 +4780,10 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing // Bash block was never renamed, must pass through as-is. bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}}}`) - out := reverseRemapOAuthToolNamesFromStreamLine(bashLine, reverseMap) + out, errReverse := reverseRemapOAuthToolNamesFromStreamLine(bashLine, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errReverse) + } if !bytes.Contains(out, []byte(`"name":"Bash"`)) { t.Fatalf("Bash should be preserved, got: %s", string(out)) } @@ -4778,7 +4793,10 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing // Glob block IS in the reverseMap, must be restored to `glob`. globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"Glob","input":{}}}`) - out = reverseRemapOAuthToolNamesFromStreamLine(globLine, reverseMap) + out, errReverse = reverseRemapOAuthToolNamesFromStreamLine(globLine, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errReverse) + } if !bytes.Contains(out, []byte(`"name":"glob"`)) { t.Fatalf("Glob should be restored to glob, got: %s", string(out)) }