Skip to content

Commit a10b2b6

Browse files
committed
fix: only relocate blocked URLs from system[], keep rest
1 parent cbaab87 commit a10b2b6

2 files changed

Lines changed: 82 additions & 84 deletions

File tree

src/transforms.test.ts

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "./transforms.ts"
99

1010
describe("transforms", () => {
11-
it("transformBody moves non-core system text to user message and PascalCase-prefixes tool names", () => {
11+
it("transformBody keeps safe system text in system[] and PascalCase-prefixes tool names", () => {
1212
const input = JSON.stringify({
1313
system: [{ type: "text", text: "OpenCode and opencode" }],
1414
tools: [{ name: "search" }],
@@ -27,20 +27,42 @@ describe("transforms", () => {
2727
}>
2828
}
2929

30-
// system should only contain the billing header (non-core text relocated)
31-
assert.equal(parsed.system.length, 1)
32-
assert.ok(
33-
parsed.system[0].text.startsWith("x-anthropic-billing-header:"),
34-
"system[0] should be the billing header",
35-
)
36-
// The original system text should now be prepended to the first user message
37-
assert.equal(parsed.messages[0].content[0].type, "text")
38-
assert.equal(parsed.messages[0].content[0].text, "OpenCode and opencode")
30+
// safe text stays in system[] (billing + original)
31+
assert.equal(parsed.system.length, 2)
32+
assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:"))
33+
assert.equal(parsed.system[1].text, "OpenCode and opencode")
34+
assert.equal(parsed.tools[0].name, "mcp_Search")
35+
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
36+
})
37+
38+
it("transformBody scrubs blocked URL from system text in-place", () => {
39+
const input = JSON.stringify({
40+
system: [{ type: "text", text: "Report at https://github.com/anomalyco/opencode for bugs" }],
41+
tools: [{ name: "search" }],
42+
messages: [
43+
{ role: "user", content: [{ type: "tool_use", name: "lookup" }] },
44+
],
45+
})
46+
47+
const output = transformBody(input)
48+
assert.equal(typeof output, "string")
49+
const parsed = JSON.parse(output as string) as {
50+
system: Array<{ text: string }>
51+
tools: Array<{ name: string }>
52+
messages: Array<{
53+
content: Array<{ type?: string; text?: string; name?: string }>
54+
}>
55+
}
56+
57+
// blocked URL scrubbed, entry stays in system[]
58+
assert.equal(parsed.system.length, 2) // billing + scrubbed entry
59+
assert.ok(!parsed.system[1].text.includes("anomalyco"))
60+
assert.ok(parsed.system[1].text.includes("Report at"))
3961
assert.equal(parsed.tools[0].name, "mcp_Search")
40-
assert.equal(parsed.messages[0].content[1].name, "mcp_Lookup")
62+
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
4163
})
4264

43-
it("transformBody relocates non-core system text to user message", () => {
65+
it("transformBody keeps safe non-core system text in system[]", () => {
4466
const input = JSON.stringify({
4567
system: [
4668
{
@@ -58,16 +80,12 @@ describe("transforms", () => {
5880
messages: Array<{ content: string }>
5981
}
6082

61-
// Non-core system text should be moved to user message
62-
assert.equal(parsed.system.length, 1) // only billing header
63-
assert.ok(
64-
parsed.messages[0].content.includes(
65-
"Use opencode-claude-auth plugin instructions as-is.",
66-
),
67-
)
83+
// Safe text stays in system[]
84+
assert.equal(parsed.system.length, 2) // billing + safe text
85+
assert.equal(parsed.system[1].text, "Use opencode-claude-auth plugin instructions as-is.")
6886
})
6987

70-
it("transformBody relocates URL/path system text to user message", () => {
88+
it("transformBody keeps safe URL/path system text in system[]", () => {
7189
const input = JSON.stringify({
7290
system: [
7391
{
@@ -85,13 +103,9 @@ describe("transforms", () => {
85103
messages: Array<{ content: string }>
86104
}
87105

88-
// Non-core system text should be relocated
89-
assert.equal(parsed.system.length, 1) // only billing header
90-
assert.ok(
91-
parsed.messages[0].content.includes(
92-
"OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin",
93-
),
94-
)
106+
// Safe URLs stay in system[]
107+
assert.equal(parsed.system.length, 2)
108+
assert.equal(parsed.system[1].text, "OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin")
95109
})
96110

97111
it("transformBody injects billing header as system[0] with computed cch", () => {
@@ -151,14 +165,11 @@ describe("transforms", () => {
151165
messages: Array<{ content: string }>
152166
}
153167

154-
// system[0] = billing header, system[1] = identity prefix
168+
// system[0] = billing header, system[1] = identity prefix, system[2] = remainder (safe)
155169
assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:"))
156170
assert.equal(parsed.system[1].text, identity)
157-
// remainder is relocated to user message
158-
assert.equal(parsed.system.length, 2)
159-
assert.ok(
160-
parsed.messages[0].content.includes("Working directory: /home/test"),
161-
)
171+
assert.equal(parsed.system.length, 3)
172+
assert.equal(parsed.system[2].text, "Working directory: /home/test")
162173
})
163174

164175
it("transformBody preserves identity without cache_control and relocates remainder", () => {
@@ -186,9 +197,9 @@ describe("transforms", () => {
186197
undefined,
187198
"Identity block must not have cache_control",
188199
)
189-
// Remainder is relocated to user message, not kept in system
190-
assert.equal(parsed.system.length, 2)
191-
assert.ok(parsed.messages[0].content.includes("More content here"))
200+
// Remainder stays in system[] (safe content)
201+
assert.equal(parsed.system.length, 3)
202+
assert.equal(parsed.system[2].text, "More content here")
192203
})
193204

194205
it("transformBody does not split identity-only system entry", () => {
@@ -238,8 +249,8 @@ describe("transforms", () => {
238249
billingEntries[0].text.includes("cch=fa690"),
239250
`Expected computed cch, got: ${billingEntries[0].text}`,
240251
)
241-
// "prompt" should be relocated to user message
242-
assert.ok(parsed.messages[0].content.includes("prompt"))
252+
// "prompt" is safe, stays in system[]
253+
assert.ok(parsed.system.some((e) => e.text === "prompt"))
243254
})
244255

245256
it("transformBody relocates multiple non-core system entries to user message as content blocks", () => {
@@ -266,24 +277,14 @@ describe("transforms", () => {
266277
}>
267278
}
268279

269-
// system should only have billing header + identity
270-
assert.equal(parsed.system.length, 2)
280+
// Safe custom blocks stay in system[]
281+
assert.equal(parsed.system.length, 4)
271282
assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:"))
272283
assert.equal(parsed.system[1].text, identity)
273-
// Both custom blocks should be prepended to user message content
274-
assert.equal(parsed.messages[0].content[0].type, "text")
275-
assert.ok(
276-
parsed.messages[0].content[0].text.includes(
277-
"Custom instructions block A",
278-
),
279-
)
280-
assert.ok(
281-
parsed.messages[0].content[0].text.includes(
282-
"Custom instructions block B",
283-
),
284-
)
285-
// Original user content preserved
286-
assert.equal(parsed.messages[0].content[1].text, "hello")
284+
assert.equal(parsed.system[2].text, "Custom instructions block A")
285+
assert.equal(parsed.system[3].text, "Custom instructions block B")
286+
// Original user content unchanged
287+
assert.equal(parsed.messages[0].content[0].text, "hello")
287288
})
288289

289290
it("transformBody keeps system intact when no messages exist", () => {
@@ -811,8 +812,7 @@ describe("transforms", () => {
811812
messages: Array<{ role: string; content: unknown }>
812813
}
813814

814-
// Orphaned tool_use message should be removed.
815-
// The user message remains, with the relocated system "prompt" prepended.
815+
// Orphaned tool_use message removed. User message stays.
816816
assert.equal(parsed.messages.length, 1)
817817
assert.equal(parsed.messages[0].role, "user")
818818
assert.ok(

src/transforms.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -169,35 +169,33 @@ export function transformBody(
169169
}
170170
parsed.system = splitSystem
171171

172-
// --- Relocate non-core system entries to user messages ---
173-
// Anthropic's API now validates the system prompt for OAuth-authenticated
174-
// requests that use Claude Code billing. Third-party system prompts
175-
// (like OpenCode's) trigger a 400 "out of extra usage" rejection when
176-
// they appear inside the system[] array alongside the identity prefix.
177-
//
178-
// Work-around: keep only the billing header and identity prefix in
179-
// system[], and prepend all other system content to the first user
180-
// message where it is functionally equivalent but avoids the check.
181-
const BILLING_PREFIX = "x-anthropic-billing-header"
182-
const keptSystem: SystemEntry[] = []
183-
const movedTexts: string[] = []
184-
for (const entry of parsed.system) {
185-
const txt = typeof entry === "string" ? entry : (entry.text ?? "")
186-
if (txt.startsWith(BILLING_PREFIX) || txt.startsWith(SYSTEM_IDENTITY)) {
187-
keptSystem.push(entry)
188-
} else if (txt.length > 0) {
189-
movedTexts.push(txt)
190-
}
172+
// --- Relocate blocked system entries to user messages ---
173+
// Anthropic's billing validator rejects specific URLs in system[]
174+
// (e.g. the OpenCode GitHub repo URL). Instead of moving ALL
175+
// non-core system content (which regresses instruction priority
176+
// and prompt-cache efficiency), only relocate entries that contain
177+
// a blocked string. Everything else stays in system[].
178+
const BLOCKED_SYSTEM_STRINGS = [
179+
"github.com/anomalyco/opencode",
180+
]
181+
182+
function isBlocked(text: string): boolean {
183+
return BLOCKED_SYSTEM_STRINGS.some((s) => text.includes(s))
191184
}
192-
if (movedTexts.length > 0 && Array.isArray(parsed.messages)) {
193-
const firstUser = parsed.messages.find((m) => m.role === "user")
194-
if (firstUser) {
195-
parsed.system = keptSystem
196-
const prefix = movedTexts.join("\n\n")
197-
if (typeof firstUser.content === "string") {
198-
firstUser.content = prefix + "\n\n" + firstUser.content
199-
} else if (Array.isArray(firstUser.content)) {
200-
firstUser.content.unshift({ type: "text", text: prefix })
185+
186+
// Scrub blocked strings from system entries in-place rather than
187+
// relocating entire entries. OpenCode concatenates the full system
188+
// prompt (identity + agent prompt + env + AGENTS + skills) into a
189+
// single text block, so moving the whole entry on a substring match
190+
// would frontload the entire prompt into the user message.
191+
for (const entry of parsed.system) {
192+
if (
193+
entry.type === "text" &&
194+
typeof entry.text === "string" &&
195+
isBlocked(entry.text)
196+
) {
197+
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
198+
entry.text = entry.text.split(blocked).join("")
201199
}
202200
}
203201
}

0 commit comments

Comments
 (0)