From a10b2b60bff4951341d1737fdd28e594f8bd93e0 Mon Sep 17 00:00:00 2001 From: s2bomb <131215297+s2bomb@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:59:13 +1000 Subject: [PATCH] fix: only relocate blocked URLs from system[], keep rest --- src/transforms.test.ts | 112 ++++++++++++++++++++--------------------- src/transforms.ts | 54 ++++++++++---------- 2 files changed, 82 insertions(+), 84 deletions(-) diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 4172fb1..2728bac 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -8,7 +8,7 @@ import { } from "./transforms.ts" describe("transforms", () => { - it("transformBody moves non-core system text to user message and PascalCase-prefixes tool names", () => { + it("transformBody keeps safe system text in system[] and PascalCase-prefixes tool names", () => { const input = JSON.stringify({ system: [{ type: "text", text: "OpenCode and opencode" }], tools: [{ name: "search" }], @@ -27,20 +27,42 @@ describe("transforms", () => { }> } - // system should only contain the billing header (non-core text relocated) - assert.equal(parsed.system.length, 1) - assert.ok( - parsed.system[0].text.startsWith("x-anthropic-billing-header:"), - "system[0] should be the billing header", - ) - // 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") + // safe text stays in system[] (billing + original) + assert.equal(parsed.system.length, 2) + assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) + assert.equal(parsed.system[1].text, "OpenCode and opencode") + assert.equal(parsed.tools[0].name, "mcp_Search") + assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup") + }) + + it("transformBody scrubs blocked URL from system text in-place", () => { + const input = JSON.stringify({ + system: [{ type: "text", text: "Report at https://github.com/anomalyco/opencode for bugs" }], + tools: [{ name: "search" }], + messages: [ + { role: "user", content: [{ type: "tool_use", name: "lookup" }] }, + ], + }) + + const output = transformBody(input) + assert.equal(typeof output, "string") + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + tools: Array<{ name: string }> + messages: Array<{ + content: Array<{ type?: string; text?: string; name?: string }> + }> + } + + // blocked URL scrubbed, entry stays in system[] + assert.equal(parsed.system.length, 2) // billing + scrubbed entry + assert.ok(!parsed.system[1].text.includes("anomalyco")) + assert.ok(parsed.system[1].text.includes("Report at")) assert.equal(parsed.tools[0].name, "mcp_Search") - assert.equal(parsed.messages[0].content[1].name, "mcp_Lookup") + assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup") }) - it("transformBody relocates non-core system text to user message", () => { + it("transformBody keeps safe non-core system text in system[]", () => { const input = JSON.stringify({ system: [ { @@ -58,16 +80,12 @@ describe("transforms", () => { messages: Array<{ content: string }> } - // Non-core system text should be moved to user message - assert.equal(parsed.system.length, 1) // only billing header - assert.ok( - parsed.messages[0].content.includes( - "Use opencode-claude-auth plugin instructions as-is.", - ), - ) + // Safe text stays in system[] + assert.equal(parsed.system.length, 2) // billing + safe text + assert.equal(parsed.system[1].text, "Use opencode-claude-auth plugin instructions as-is.") }) - it("transformBody relocates URL/path system text to user message", () => { + it("transformBody keeps safe URL/path system text in system[]", () => { const input = JSON.stringify({ system: [ { @@ -85,13 +103,9 @@ describe("transforms", () => { messages: Array<{ content: string }> } - // Non-core system text should be relocated - assert.equal(parsed.system.length, 1) // only billing header - assert.ok( - parsed.messages[0].content.includes( - "OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin", - ), - ) + // Safe URLs stay in system[] + assert.equal(parsed.system.length, 2) + assert.equal(parsed.system[1].text, "OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin") }) it("transformBody injects billing header as system[0] with computed cch", () => { @@ -151,14 +165,11 @@ describe("transforms", () => { messages: Array<{ content: string }> } - // system[0] = billing header, system[1] = identity prefix + // system[0] = billing header, system[1] = identity prefix, system[2] = remainder (safe) assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) assert.equal(parsed.system[1].text, identity) - // remainder is relocated to user message - assert.equal(parsed.system.length, 2) - assert.ok( - parsed.messages[0].content.includes("Working directory: /home/test"), - ) + assert.equal(parsed.system.length, 3) + assert.equal(parsed.system[2].text, "Working directory: /home/test") }) it("transformBody preserves identity without cache_control and relocates remainder", () => { @@ -186,9 +197,9 @@ describe("transforms", () => { undefined, "Identity block must not have cache_control", ) - // Remainder is relocated to user message, not kept in system - assert.equal(parsed.system.length, 2) - assert.ok(parsed.messages[0].content.includes("More content here")) + // Remainder stays in system[] (safe content) + assert.equal(parsed.system.length, 3) + assert.equal(parsed.system[2].text, "More content here") }) it("transformBody does not split identity-only system entry", () => { @@ -238,8 +249,8 @@ describe("transforms", () => { billingEntries[0].text.includes("cch=fa690"), `Expected computed cch, got: ${billingEntries[0].text}`, ) - // "prompt" should be relocated to user message - assert.ok(parsed.messages[0].content.includes("prompt")) + // "prompt" is safe, stays in system[] + assert.ok(parsed.system.some((e) => e.text === "prompt")) }) it("transformBody relocates multiple non-core system entries to user message as content blocks", () => { @@ -266,24 +277,14 @@ describe("transforms", () => { }> } - // system should only have billing header + identity - assert.equal(parsed.system.length, 2) + // Safe custom blocks stay in system[] + assert.equal(parsed.system.length, 4) assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) assert.equal(parsed.system[1].text, identity) - // Both custom blocks should be prepended to user message content - assert.equal(parsed.messages[0].content[0].type, "text") - assert.ok( - parsed.messages[0].content[0].text.includes( - "Custom instructions block A", - ), - ) - assert.ok( - parsed.messages[0].content[0].text.includes( - "Custom instructions block B", - ), - ) - // Original user content preserved - assert.equal(parsed.messages[0].content[1].text, "hello") + assert.equal(parsed.system[2].text, "Custom instructions block A") + assert.equal(parsed.system[3].text, "Custom instructions block B") + // Original user content unchanged + assert.equal(parsed.messages[0].content[0].text, "hello") }) it("transformBody keeps system intact when no messages exist", () => { @@ -811,8 +812,7 @@ describe("transforms", () => { messages: Array<{ role: string; content: unknown }> } - // Orphaned tool_use message should be removed. - // The user message remains, with the relocated system "prompt" prepended. + // Orphaned tool_use message removed. User message stays. assert.equal(parsed.messages.length, 1) assert.equal(parsed.messages[0].role, "user") assert.ok( diff --git a/src/transforms.ts b/src/transforms.ts index 2bf3a6a..27ac3b6 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -169,35 +169,33 @@ export function transformBody( } parsed.system = splitSystem - // --- Relocate non-core system entries to user messages --- - // Anthropic's API now validates the system prompt for OAuth-authenticated - // requests that use Claude Code billing. Third-party system prompts - // (like OpenCode's) trigger a 400 "out of extra usage" rejection when - // they appear inside the system[] array alongside the identity prefix. - // - // Work-around: keep only the billing header and identity prefix in - // system[], and prepend all other system content to the first user - // message where it is functionally equivalent but avoids the check. - const BILLING_PREFIX = "x-anthropic-billing-header" - const keptSystem: SystemEntry[] = [] - const movedTexts: string[] = [] - for (const entry of parsed.system) { - const txt = typeof entry === "string" ? entry : (entry.text ?? "") - if (txt.startsWith(BILLING_PREFIX) || txt.startsWith(SYSTEM_IDENTITY)) { - keptSystem.push(entry) - } else if (txt.length > 0) { - movedTexts.push(txt) - } + // --- Relocate blocked system entries to user messages --- + // Anthropic's billing validator rejects specific URLs in system[] + // (e.g. the OpenCode GitHub repo URL). Instead of moving ALL + // non-core system content (which regresses instruction priority + // and prompt-cache efficiency), only relocate entries that contain + // a blocked string. Everything else stays in system[]. + const BLOCKED_SYSTEM_STRINGS = [ + "github.com/anomalyco/opencode", + ] + + function isBlocked(text: string): boolean { + return BLOCKED_SYSTEM_STRINGS.some((s) => text.includes(s)) } - if (movedTexts.length > 0 && Array.isArray(parsed.messages)) { - const firstUser = parsed.messages.find((m) => m.role === "user") - if (firstUser) { - parsed.system = keptSystem - const prefix = movedTexts.join("\n\n") - if (typeof firstUser.content === "string") { - firstUser.content = prefix + "\n\n" + firstUser.content - } else if (Array.isArray(firstUser.content)) { - firstUser.content.unshift({ type: "text", text: prefix }) + + // Scrub blocked strings from system entries in-place rather than + // relocating entire entries. OpenCode concatenates the full system + // prompt (identity + agent prompt + env + AGENTS + skills) into a + // single text block, so moving the whole entry on a substring match + // would frontload the entire prompt into the user message. + for (const entry of parsed.system) { + if ( + entry.type === "text" && + typeof entry.text === "string" && + isBlocked(entry.text) + ) { + for (const blocked of BLOCKED_SYSTEM_STRINGS) { + entry.text = entry.text.split(blocked).join("") } } }