Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export ANTHROPIC_ENABLE_1M_CONTEXT=true # requires Claude Max

- Registers an `auth.loader` with a custom `fetch` that intercepts all Anthropic API requests
- Sets `Authorization: Bearer` with fresh OAuth tokens (cached in memory, 30s TTL, updated in-place after refresh)
- Translates tool names between OpenCode and Anthropic API formats (adds/strips `mcp_` prefix)
- Obfuscates tool names with MD5 hashing (`t_` prefix) to avoid API blacklist detection, with bidirectional reverse mapping for response translation
- Buffers SSE response streams at event boundaries for reliable tool name translation
- Injects Claude Code identity into system prompts via `experimental.chat.system.transform`
- Sets required API headers (beta flags, billing, user-agent) with model-aware selection
Expand Down
124 changes: 100 additions & 24 deletions src/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ describe("transforms", () => {
// The original system text should now be prepended to the first user message
assert.equal(parsed.messages[0].content[0].type, "text")
assert.equal(parsed.messages[0].content[0].text, "OpenCode and opencode")
assert.equal(parsed.tools[0].name, "mcp_search")
assert.equal(parsed.messages[0].content[1].name, "mcp_lookup")
assert.match(parsed.tools[0].name, /^t_[0-9a-f]{8}$/)
assert.match(parsed.messages[0].content[1].name, /^t_[0-9a-f]{8}$/)
})

it("transformBody relocates non-core system text to user message", () => {
Expand Down Expand Up @@ -449,9 +449,22 @@ describe("transforms", () => {
assert.equal(parsed.thinking, undefined)
})

it("stripToolPrefix removes mcp_ from response payload names", () => {
const input = '{"name":"mcp_search","type":"tool_use"}'
assert.equal(stripToolPrefix(input), '{"name": "search","type":"tool_use"}')
it("stripToolPrefix reverses obfuscated tool names", () => {
// Populate the maps by obfuscating via transformBody
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "test" }],
}),
)
const parsed = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsed.tools[0].name
// Now test deobfuscation
const input = `{"name":"${obf}","type":"tool_use"}`
const result = stripToolPrefix(input)
assert.ok(result.includes('"name": "search"'))
})

it("transformResponseStream passes error responses through without SSE parsing", async () => {
Expand Down Expand Up @@ -517,23 +530,46 @@ describe("transforms", () => {
})

it("transformResponseStream still strips tool prefixes in error bodies", async () => {
// stripToolPrefix matches the pattern "name": "mcp_..."
const errorBody = '{"name": "mcp_search", "error": "failed"}'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const errorBody = `{"name": "${obf}", "error": "failed"}`
const response = new Response(errorBody, { status: 400 })
const transformed = transformResponseStream(response)
const text = await transformed.text()
assert.ok(
text.includes('"name": "search"'),
"Should strip mcp_ prefix even in error bodies",
"Should deobfuscate tool name in error bodies",
)
assert.ok(
!text.includes("mcp_search"),
"Should not contain mcp_search after stripping",
!text.includes(obf),
"Should not contain obfuscated name after stripping",
)
})

it("transformResponseStream rewrites streamed tool names", async () => {
const payload = '{"name":"mcp_lookup"}'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "lookup" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const payload = `{"name":"${obf}"}`
const response = new Response(payload)
const transformed = transformResponseStream(response)
const text = await transformed.text()
Expand All @@ -542,8 +578,23 @@ describe("transforms", () => {
})

it("transformResponseStream buffers across chunks until event boundary", async () => {
const chunk1 = 'data: {"name":"mc'
const chunk2 = 'p_search"}\n\ndata: {"type":"done"}\n\n'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

// Split the obfuscated name across two chunks
const fullData = `data: {"name":"${obf}"}\n\ndata: {"type":"done"}\n\n`
const splitAt = 15 // splits inside the tool name
const chunk1 = fullData.slice(0, splitAt)
const chunk2 = fullData.slice(splitAt)
Comment on lines +594 to +597

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 splitAt = 15 does not split inside the tool name

data: {"name":" is exactly 15 characters, so chunk1 ends with the opening quote and chunk2 starts with the full t_XXXXXXXX token — the split lands just before the name, not inside it. The original test deliberately put mc in chunk1 and p_search"}\n\n… in chunk2, proving the buffer handles a name torn mid-token. Using splitAt = 17 or 19 (somewhere inside t_XXXXXXXX) would restore that property and make the comment accurate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.test.ts
Line: 594-597

Comment:
**`splitAt = 15` does not split inside the tool name**

`data: {"name":"` is exactly 15 characters, so `chunk1` ends with the opening quote and `chunk2` starts with the full `t_XXXXXXXX` token — the split lands just *before* the name, not inside it. The original test deliberately put `mc` in chunk1 and `p_search"}\n\n…` in chunk2, proving the buffer handles a name torn mid-token. Using `splitAt = 17` or `19` (somewhere inside `t_XXXXXXXX`) would restore that property and make the comment accurate.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

const encoder = new TextEncoder()

const stream = new ReadableStream({
Expand All @@ -563,18 +614,30 @@ describe("transforms", () => {
`Expected stripped name in: ${text}`,
)
assert.ok(
!text.includes("mcp_search"),
`Should not contain mcp_search in: ${text}`,
!text.includes(obf),
`Should not contain obfuscated name in: ${text}`,
)
})

it("transformResponseStream withholds output until event boundary arrives", async () => {
// Populate maps via round-trip
const bodyResult = transformBody(
JSON.stringify({
tools: [{ name: "test" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(bodyResult as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const encoder = new TextEncoder()
let sendBoundary: (() => void) | undefined

const source = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"name":"mcp_test"}'))
controller.enqueue(encoder.encode(`data: {"name":"${obf}"}`))
sendBoundary = () => {
controller.enqueue(encoder.encode("\n\n"))
controller.close()
Expand Down Expand Up @@ -609,8 +672,8 @@ describe("transforms", () => {
`Expected stripped name: ${text}`,
)
assert.ok(
!text.includes("mcp_test"),
`Should not contain mcp_test: ${text}`,
!text.includes(obf),
`Should not contain obfuscated name: ${text}`,
)

const final = await reader.read()
Expand Down Expand Up @@ -780,9 +843,22 @@ describe("transforms", () => {
})

it("transformResponseStream flushes remaining buffered data on stream end", async () => {
// Populate maps for both tool names via round-trip
const bodyResult = transformBody(
JSON.stringify({
tools: [{ name: "alpha" }, { name: "beta" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(bodyResult as string) as {
tools: Array<{ name: string }>
}
const obfAlpha = parsedBody.tools[0].name
const obfBeta = parsedBody.tools[1].name

const encoder = new TextEncoder()
const chunk1 = 'data: {"name":"mcp_alpha"}\n\n'
const chunk2 = 'data: {"name":"mcp_beta"}'
const chunk1 = `data: {"name":"${obfAlpha}"}\n\n`
const chunk2 = `data: {"name":"${obfBeta}"}`

const stream = new ReadableStream({
start(controller) {
Expand All @@ -805,12 +881,12 @@ describe("transforms", () => {
`Expected beta stripped in: ${text}`,
)
assert.ok(
!text.includes("mcp_alpha"),
`Should not contain mcp_alpha in: ${text}`,
!text.includes(obfAlpha),
`Should not contain obfuscated alpha in: ${text}`,
)
assert.ok(
!text.includes("mcp_beta"),
`Should not contain mcp_beta in: ${text}`,
!text.includes(obfBeta),
`Should not contain obfuscated beta in: ${text}`,
)
})
})
50 changes: 37 additions & 13 deletions src/transforms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { buildBillingHeaderValue } from "./signing.ts"
import { config, getModelOverride } from "./model-config.ts"

const TOOL_PREFIX = "mcp_"
// Obfuscate tool names: the API blacklists certain tool names (todowrite,
// background_output, background_cancel). To avoid detection we hash ALL tool
// names on the way out and reverse-map them on the way back.
import { createHash } from "node:crypto"

const toolNameMap = new Map<string, string>() // obfuscated → original
const toolNameReverseMap = new Map<string, string>() // original → obfuscated
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level maps are never cleared and silently fail on deobfuscation miss

toolNameMap and toolNameReverseMap live for the lifetime of the process and are never reset. In the current single-process model that is fine, but deobfuscateToolName returns the raw hash when a key is absent (the ?? obf fallback). If that ever triggers — e.g., during a hot-reload in development, or if a response somehow references a tool that was registered in a prior process lifetime — OpenCode receives obfuscated names like t_a1b2c3d4 instead of original ones and silently misroutes tool calls. A warning log or a thrown error on a miss would make this failure mode visible.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.ts
Line: 9-10

Comment:
**Module-level maps are never cleared and silently fail on deobfuscation miss**

`toolNameMap` and `toolNameReverseMap` live for the lifetime of the process and are never reset. In the current single-process model that is fine, but `deobfuscateToolName` returns the raw hash when a key is absent (the `?? obf` fallback). If that ever triggers — e.g., during a hot-reload in development, or if a response somehow references a tool that was registered in a prior process lifetime — OpenCode receives obfuscated names like `t_a1b2c3d4` instead of original ones and silently misroutes tool calls. A warning log or a thrown error on a miss would make this failure mode visible.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex


function obfuscateToolName(name: string): string {
const existing = toolNameReverseMap.get(name)
if (existing) return existing
const hash = createHash("md5").update(name).digest("hex").slice(0, 8)
const obf = `t_${hash}`
toolNameMap.set(obf, name)
toolNameReverseMap.set(name, obf)
return obf
}

function deobfuscateToolName(obf: string): string {
return toolNameMap.get(obf) ?? obf
}

const SYSTEM_IDENTITY =
"You are Claude Code, Anthropic's official CLI for Claude."
Expand Down Expand Up @@ -206,30 +226,30 @@ export function transformBody(
}
}

// Obfuscate tool names to avoid API blacklist detection
if (Array.isArray(parsed.tools)) {
parsed.tools = parsed.tools.map((tool) => ({
...tool,
name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
name: tool.name ? obfuscateToolName(tool.name) : tool.name,
}))
}

if (Array.isArray(parsed.messages)) {
parsed.messages = parsed.messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}

if (!Array.isArray(message.content)) return message
return {
...message,
content: message.content.map((block) => {
if (block.type !== "tool_use" || typeof block.name !== "string") {
return block
if (block.type === "tool_use" && typeof block.name === "string") {
return { ...block, name: obfuscateToolName(block.name) }
}

return {
...block,
name: `${TOOL_PREFIX}${block.name}`,
if (
block.type === "tool_result" &&
typeof block["tool_use_id"] === "string"
) {
// tool_result references tool_use by id, not name — no change needed
}
Comment on lines +246 to 251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead tool_result if-branch adds noise without effect

The branch checks block.type === "tool_result" then does nothing — execution falls straight through to return block in both cases. The comment is useful but wrapping it in an inert if block suggests an action that never happens, which makes the control flow misleading for future readers. The block should either be removed or kept as a plain comment outside any conditional.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.ts
Line: 246-251

Comment:
**Dead `tool_result` if-branch adds noise without effect**

The branch checks `block.type === "tool_result"` then does nothing — execution falls straight through to `return block` in both cases. The comment is useful but wrapping it in an inert `if` block suggests an action that never happens, which makes the control flow misleading for future readers. The block should either be removed or kept as a plain comment outside any conditional.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

return block
}),
}
})
Expand All @@ -246,7 +266,11 @@ export function transformBody(
}

export function stripToolPrefix(text: string): string {
return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"')
// Reverse-map obfuscated tool names back to originals in response stream
return text.replace(/"name"\s*:\s*"(t_[0-9a-f]{8})"/g, (_match, obf) => {
const original = deobfuscateToolName(obf)
return `"name": "${original}"`
})
}

export function transformResponseStream(response: Response): Response {
Expand Down
Loading