-
Notifications
You must be signed in to change notification settings - Fork 657
[WRONG BRANCH] fix(providers): un-obfuscate GitHub Copilot streamed tool-call arguments #990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0602e6f
6af96da
c5d5b25
9542d69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,7 +155,11 @@ import { | |
| imageGenToolCallAliases, | ||
| restoreImageGenCallsInJson, | ||
| } from "../responses-image-gen-repair"; | ||
| import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite"; | ||
| import { | ||
| composeSsePayloadRewrites, | ||
| createGithubCopilotObfuscationRewrite, | ||
| relaySseWithPayloadRewrite, | ||
| } from "../sse-payload-rewrite"; | ||
| import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; | ||
|
|
||
| import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; | ||
|
|
@@ -1765,6 +1769,9 @@ async function handleResponsesInner( | |
| hasResponsesItemIdRepair(repairConfig) | ||
| ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) | ||
| : undefined, | ||
| route.providerName === "github-copilot" | ||
| ? createGithubCopilotObfuscationRewrite() | ||
| : undefined, | ||
|
Comment on lines
+1772
to
+1774
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a GitHub Copilot Responses stream with neither image-generation aliases nor item-ID repair—the normal tool-call case—the new rewrite is added to AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined); | ||
| // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain | ||
| // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,26 @@ import type { TranslatorBudget } from "../lib/translator-budget"; | |
|
|
||
| export type SsePayloadRewrite = (payload: string) => string; | ||
|
|
||
| function isPayloadRecord(v: unknown): v is Record<string, unknown> { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Copilot encrypts reasoning summaries (and re-encrypts them per event) with its own | ||
| * scheme, which Responses clients cannot decrypt; the app then stays stuck on "thinking". | ||
| * Replace the ciphertext with a canonical empty plaintext summary so the item completes | ||
| * immediately and the turn moves on to the actual output. | ||
| */ | ||
| function sanitizeGithubCopilotReasoningItem(item: Record<string, unknown>): boolean { | ||
| if (item.type !== "reasoning" || typeof item.encrypted_content !== "string") return false; | ||
| delete item.encrypted_content; | ||
| const summary = Array.isArray(item.summary) ? item.summary : []; | ||
| summary.push({ type: "summary_text", text: "" }); | ||
| item.summary = summary; | ||
| if (!Array.isArray(item.content)) item.content = []; | ||
| return true; | ||
| } | ||
|
|
||
| /** Split one complete SSE event block while retaining its original blank-line delimiter. */ | ||
| export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null { | ||
| const match = buffer.match(/\r?\n\r?\n/); | ||
|
|
@@ -61,6 +81,108 @@ export function composeSsePayloadRewrites(...rewrites: SsePayloadRewrite[]): Sse | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Copilot's `/responses` wire obfuscates streamed function-call argument | ||
| * deltas (ciphertext `delta` plus an `obfuscation` field) for the `vscode-chat` | ||
| * integration. Responses clients (codex-rs) cannot de-obfuscate them, so an | ||
| * agentic turn stalls on the first tool call. The terminal | ||
| * `response.function_call_arguments.done` event carries the plaintext | ||
| * `arguments`, so this rewrite drops the ciphertext deltas and re-emits the | ||
| * full plaintext arguments as a single delta immediately before `done`, keeping | ||
| * the stream protocol-conformant (deltas still concatenate to the final | ||
| * arguments). The `obfuscation` metadata is stripped from every event. | ||
| */ | ||
| export function createGithubCopilotObfuscationRewrite(): SsePayloadRewrite { | ||
| // GitHub re-encrypts `item_id` per event (delta and done carry different ids for the | ||
| // same logical call), so calls are tracked by the stable `output_index` instead. | ||
| const obfuscatedCalls = new Set<number>(); | ||
| // GitHub also re-encrypts the response id and every item id per event (`created`/`done` | ||
| // and the terminal `completed` payload disagree). Clients reconcile streamed items with | ||
| // the completed response by id, so pin every entity to its first-seen id. | ||
| let responseId: string | null = null; | ||
| const itemIds = new Map<number, string>(); | ||
| return (payload: string): string => { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(payload); | ||
| } catch { | ||
| return payload; | ||
| } | ||
| if (!isPayloadRecord(parsed)) return payload; | ||
| let changed = false; | ||
| if (typeof parsed.obfuscation === "string") { | ||
| delete parsed.obfuscation; | ||
| changed = true; | ||
| } | ||
| const type = parsed.type; | ||
| if (type === "response.function_call_arguments.delta" && changed) { | ||
| if (typeof parsed.output_index === "number") obfuscatedCalls.add(parsed.output_index); | ||
| // Ciphertext chunk: emit an empty delta so the client sees a valid event | ||
| // without any unusable bytes; the plaintext arrives with `.done`. | ||
| parsed.delta = ""; | ||
| return JSON.stringify(parsed); | ||
| } | ||
| if (type === "response.function_call_arguments.done" && typeof parsed.output_index === "number") { | ||
| if (obfuscatedCalls.delete(parsed.output_index)) { | ||
| const deltaEvent = { | ||
| type: "response.function_call_arguments.delta", | ||
| item_id: parsed.item_id, | ||
| output_index: parsed.output_index, | ||
| sequence_number: parsed.sequence_number, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The inserted delta copies the done event's AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| delta: typeof parsed.arguments === "string" ? parsed.arguments : "", | ||
| }; | ||
| // The relay writes `data: <payload>` then the block delimiter, so this | ||
| // payload becomes two consecutive valid SSE events (delta, then done). | ||
| return `${JSON.stringify(deltaEvent)}\n\ndata: ${JSON.stringify(parsed)}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the upstream done frame includes AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| if (type === "response.created" && isPayloadRecord(parsed.response)) { | ||
| const id = parsed.response.id; | ||
| if (typeof id === "string") { | ||
| if (responseId === null) responseId = id; | ||
| else if (id !== responseId) { | ||
| parsed.response.id = responseId; | ||
| changed = true; | ||
| } | ||
| } | ||
| } | ||
| if (type === "response.output_item.added" || type === "response.output_item.done") { | ||
| const outputIndex = parsed.output_index; | ||
| const item = parsed.item; | ||
| if (typeof outputIndex === "number" && isPayloadRecord(item) && typeof item.id === "string") { | ||
| const existing = itemIds.get(outputIndex); | ||
| if (existing === undefined) itemIds.set(outputIndex, item.id); | ||
| else if (item.id !== existing) { | ||
| item.id = existing; | ||
| changed = true; | ||
| } | ||
| } | ||
| if (isPayloadRecord(item) && sanitizeGithubCopilotReasoningItem(item)) changed = true; | ||
| } | ||
| if (type === "response.completed" && isPayloadRecord(parsed.response)) { | ||
| if (typeof parsed.response.id === "string" && responseId !== null && parsed.response.id !== responseId) { | ||
| parsed.response.id = responseId; | ||
| changed = true; | ||
| } | ||
| if (Array.isArray(parsed.response.output)) { | ||
| parsed.response.output.forEach((item, outputIndex) => { | ||
| if (!isPayloadRecord(item)) return; | ||
| if (typeof item.id === "string") { | ||
| const existing = itemIds.get(outputIndex); | ||
| if (existing === undefined) itemIds.set(outputIndex, item.id); | ||
| else if (item.id !== existing) { | ||
| item.id = existing; | ||
| changed = true; | ||
| } | ||
| } | ||
| if (sanitizeGithubCopilotReasoningItem(item)) changed = true; | ||
| }); | ||
| } | ||
| } | ||
| return changed ? JSON.stringify(parsed) : payload; | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Relay an SSE body through a single JS pull wrapper, rewriting each event's data payload in place. | ||
| * Non-data fields and framing are preserved; invalid JSON payloads are left to the rewrite callback. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Include the Copilot rewrite in
needsClientRewrite.On Line 1765,
needsClientRewriteexcludes the condition added on Lines 1772-1774. On Windows,clientBodythen returnsnativeBodyand bypassesrewrittenBody. IfselectEagerPathselects the eager relay, it also omitsrewritePayloadbecausewin32EagerRewriteuses the same false gate. The client receives the original ciphertext deltas.Define one Copilot rewrite flag. Include it in
needsClientRewrite. Use the same flag when buildingpayloadRewrites. Add a focused regression test for the Windows and eager relay paths.Proposed fix
Based on the PR objective, the rewrite must apply to both native-passthrough relay paths. As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions