diff --git a/extensions/github-copilot/connection-bound-ids.test.ts b/extensions/github-copilot/connection-bound-ids.test.ts index 7a0e821675c5..985bc15aa5fa 100644 --- a/extensions/github-copilot/connection-bound-ids.test.ts +++ b/extensions/github-copilot/connection-bound-ids.test.ts @@ -1,9 +1,9 @@ // Github Copilot tests cover connection bound ids plugin behavior. import { describe, expect, it } from "vitest"; -import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js"; +import { sanitizeCopilotReplayResponsePayload } from "./connection-bound-ids.js"; function rewriteInputIds(input: unknown): boolean { - return rewriteCopilotResponsePayloadConnectionBoundIds({ input }); + return sanitizeCopilotReplayResponsePayload({ input }); } describe("github-copilot connection-bound response IDs", () => { @@ -37,7 +37,7 @@ describe("github-copilot connection-bound response IDs", () => { expect(input[4]?.id).toMatch(/^msg_[a-f0-9]{16}$/); }); - it("preserves valid reasoning IDs regardless of encrypted_content", () => { + it("preserves valid reasoning IDs but strips encrypted_content", () => { const withEncrypted = Buffer.from(`reasoning-${"e".repeat(24)}`).toString("base64"); const withNull = Buffer.from(`reasoning-${"n".repeat(24)}`).toString("base64"); const withoutField = Buffer.from(`reasoning-${"a".repeat(24)}`).toString("base64"); @@ -47,13 +47,13 @@ describe("github-copilot connection-bound response IDs", () => { { id: withoutField, type: "reasoning" }, ]; - expect(rewriteInputIds(input)).toBe(false); - expect(input[0]?.id).toBe(withEncrypted); - expect(input[1]?.id).toBe(withNull); - expect(input[2]?.id).toBe(withoutField); + expect(rewriteInputIds(input)).toBe(true); + expect(input[0]).toEqual({ id: withEncrypted, type: "reasoning" }); + expect(input[1]).toEqual({ id: withNull, type: "reasoning" }); + expect(input[2]).toEqual({ id: withoutField, type: "reasoning" }); }); - it("preserves valid base64-ish reasoning IDs with and without encrypted content", () => { + it("strips encrypted_content from valid reasoning IDs at payload send time", () => { const withEncrypted = "abcDEF0123+/="; const withoutEncrypted = "reasoning/abc+123="; const input = [ @@ -61,11 +61,12 @@ describe("github-copilot connection-bound response IDs", () => { { id: withoutEncrypted, type: "reasoning" }, ]; - expect(rewriteInputIds(input)).toBe(false); - expect(input.map((item) => item.id)).toEqual([withEncrypted, withoutEncrypted]); + expect(rewriteInputIds(input)).toBe(true); + expect(input[0]).toEqual({ id: withEncrypted, type: "reasoning" }); + expect(input[1]).toEqual({ id: withoutEncrypted, type: "reasoning" }); }); - it("drops unsafe reasoning replay item IDs while keeping idless reasoning replay", () => { + it("drops unsafe reasoning IDs and strips encrypted_content from kept items", () => { const overlongId = `5PX6gLHXT5wE+Y2tPmUV4gn+${"B".repeat(384)}`; const input = [ { @@ -81,8 +82,8 @@ describe("github-copilot connection-bound response IDs", () => { expect(rewriteInputIds(input)).toBe(true); expect(input).toEqual([ - { type: "reasoning", encrypted_content: "missing-id", summary: [] }, - { id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] }, + { type: "reasoning", summary: [] }, + { id: "rs_valid", type: "reasoning", summary: [] }, ]); }); @@ -90,9 +91,9 @@ describe("github-copilot connection-bound response IDs", () => { const messageId = Buffer.from(`message-${"m".repeat(24)}`).toString("base64"); const payload = { input: [{ id: messageId, type: "message" }] }; - expect(rewriteCopilotResponsePayloadConnectionBoundIds(payload)).toBe(true); + expect(sanitizeCopilotReplayResponsePayload(payload)).toBe(true); expect(payload.input[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/); - expect(rewriteCopilotResponsePayloadConnectionBoundIds(undefined)).toBe(false); - expect(rewriteCopilotResponsePayloadConnectionBoundIds({ input: "text" })).toBe(false); + expect(sanitizeCopilotReplayResponsePayload(undefined)).toBe(false); + expect(sanitizeCopilotReplayResponsePayload({ input: "text" })).toBe(false); }); }); diff --git a/extensions/github-copilot/connection-bound-ids.ts b/extensions/github-copilot/connection-bound-ids.ts index e5023baac9af..419cf7c33dba 100644 --- a/extensions/github-copilot/connection-bound-ids.ts +++ b/extensions/github-copilot/connection-bound-ids.ts @@ -45,13 +45,16 @@ function sanitizeCopilotReplayResponseIds(input: unknown): boolean { continue; } const id = item.id; - // Reasoning items with replay IDs reference server-side encrypted state - // bound to that ID. Drop unsafe IDs, but keep the store-disabled idless - // replay form produced by core Responses conversion. + // Reasoning encrypted_content is tied to the Copilot connection token, + // which rotates per request. Drop items with unsafe IDs; strip + // encrypted_content from kept items so summary-only replay is sent. if (item.type === "reasoning") { if (id !== undefined && !isValidReasoningReplayId(id)) { input.splice(index, 1); rewrote = true; + } else if ("encrypted_content" in item) { + delete item.encrypted_content; + rewrote = true; } continue; } @@ -66,13 +69,9 @@ function sanitizeCopilotReplayResponseIds(input: unknown): boolean { return rewrote; } -function sanitizeCopilotReplayResponsePayloadIds(payload: unknown): boolean { +export function sanitizeCopilotReplayResponsePayload(payload: unknown): boolean { if (!payload || typeof payload !== "object") { return false; } return sanitizeCopilotReplayResponseIds((payload as { input?: unknown }).input); } - -export function rewriteCopilotResponsePayloadConnectionBoundIds(payload: unknown): boolean { - return sanitizeCopilotReplayResponsePayloadIds(payload); -} diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index 6a579150022d..ebe317d4da42 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -240,6 +240,8 @@ describe("wrapCopilotAnthropicStream", () => { ]); expect(payloads[0]?.input[1]?.id).toBeUndefined(); expect(payloads[0]?.input[2]?.id).toMatch(/^msg_[a-f0-9]{16}$/); + expect(payloads[0]?.input[0]).not.toHaveProperty("encrypted_content"); + expect(payloads[0]?.input[1]).not.toHaveProperty("encrypted_content"); }); it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => { diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 41962aaa4dfd..9681e2f7a85b 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -7,7 +7,7 @@ import { applyAnthropicEphemeralCacheControlMarkers, streamWithPayloadPatch, } from "openclaw/plugin-sdk/provider-stream-shared"; -import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js"; +import { sanitizeCopilotReplayResponsePayload } from "./connection-bound-ids.js"; import { stripCopilotAssistantThinkingMessages } from "./replay-policy.js"; type StreamOptions = Parameters[2]; @@ -62,11 +62,11 @@ function buildCopilotDynamicHeaders(params: { function patchOnPayloadResult(result: unknown): unknown { if (result && typeof result === "object" && "then" in result) { return Promise.resolve(result).then((next) => { - rewriteCopilotResponsePayloadConnectionBoundIds(next); + sanitizeCopilotReplayResponsePayload(next); return next; }); } - rewriteCopilotResponsePayloadConnectionBoundIds(result); + sanitizeCopilotReplayResponsePayload(result); return result; } @@ -132,7 +132,7 @@ function wrapCopilotOpenAIResponsesStream( ...options, headers: buildCopilotRequestHeaders(context, options?.headers), onPayload: (payload, payloadModel) => { - rewriteCopilotResponsePayloadConnectionBoundIds(payload); + sanitizeCopilotReplayResponsePayload(payload); return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel)); }, };