diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index b73a69c3eb..63d9afafd0 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -16,6 +16,7 @@ import { prepareToolMessages, buildToolAwareResult } from "../translator/webTool import type { Session } from "../services/sessionPool/session.ts"; import { tryBackedChat } from "../services/browserBackedChat.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { normalizeSystemRole } from "../services/roleNormalizer.ts"; // Issue #6999: Lightweight circuit breaker for the DuckDuckGo executor. // After CB_THRESHOLD consecutive failures (429, 5xx, or network errors), @@ -559,8 +560,17 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } } + // #ddgw defense-in-depth: duckchat/v1/chat accepts only user/assistant roles. + // Normalize after catalog resolution so the effective upstream model is used. + // This also shields the system tool prompt injected by prepareToolMessages. + const normalizedMessages = normalizeSystemRole( + messages, + "duckduckgo-web", + upstreamModel + ) as typeof messages; + const sendChat = async (vqdHeaders: DuckDuckGoAuthHeaders): Promise => { - const payload = buildDuckDuckGoPayload(upstreamModel, messages); + const payload = buildDuckDuckGoPayload(upstreamModel, normalizedMessages); const response = await fetch(CHAT_URL, { method: "POST", headers: mergeHeadersCaseInsensitive( @@ -786,10 +796,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { try { return { vqd4: retry.vqd4, - vqdHash1: await solveDuckDuckGoChallenge( - retry.vqdHash1, - FAKE_HEADERS["User-Agent"] - ), + vqdHash1: await solveDuckDuckGoChallenge(retry.vqdHash1, FAKE_HEADERS["User-Agent"]), status: retry.status, retryAfter: retry.retryAfter, }; diff --git a/open-sse/services/roleNormalizer.ts b/open-sse/services/roleNormalizer.ts index b7190a67a6..51268eeaef 100644 --- a/open-sse/services/roleNormalizer.ts +++ b/open-sse/services/roleNormalizer.ts @@ -23,6 +23,11 @@ const PROVIDERS_WITHOUT_SYSTEM_ROLE = new Set([ // Known to reject system role (from troubleshooting report) // GLM uses Claude format, so this is handled through claude translator // But if accessed through OpenAI-format providers like nvidia, it needs this: + // DuckDuckGo duck.ai (duckchat/v1/chat) accepts only user/assistant roles — a + // system/developer message yields 400 ERR_BAD_REQUEST (#ddgw). Registry id + + // alias are both listed because either may arrive as the routing provider id. + "duckduckgo-web", + "ddgw", ]); /** diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index 06543dac88..f816a63663 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -1,9 +1,10 @@ -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import assert from "node:assert/strict"; import { FETCH_TIMEOUT_MS } from "../../open-sse/config/constants.ts"; import { DuckDuckGoWebExecutor, DUCKDUCKGO_BASE, + CHAT_URL, normalizeDuckDuckGoMessages, STATUS_URL, } from "../../open-sse/executors/duckduckgo-web.ts"; @@ -231,6 +232,122 @@ describe("DuckDuckGoWebExecutor", () => { }); }); + describe("system-role shielding (#ddgw)", () => { + type ExecuteArgs = Parameters[0]; + // duck.ai's duckchat/v1/chat rejects role:"system" with 400 ERR_BAD_REQUEST. + // The translator-side normalizer folds system/developer into the first user + // message; this shield guarantees the executor never forwards such roles + // upstream even when a future bypass reintroduces them after translation + // (e.g. prepareToolMessages' injected tool prompt). + function mockDuckChat(t: TestContext, capturedBodies: unknown[]): void { + t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (method === "GET" && url === STATUS_URL) { + return new Response(null, { + status: 200, + headers: { "x-vqd-4": "test-vqd-4" }, + }); + } + if (method === "POST" && url === CHAT_URL) { + capturedBodies.push(JSON.parse(String(init?.body))); + return new Response('data: {"message":"OK"}\n\ndata: [DONE]\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + // warmSession + country/token fetches tolerate empty 2xx responses. + return new Response(null, { status: 200 }); + }); + } + + it("folds a leading system message into the first user message before the upstream POST", async (t) => { + const captured: unknown[] = []; + mockDuckChat(t, captured); + + await new DuckDuckGoWebExecutor().execute({ + model: "gpt-5.4-nano", + body: { + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Reply with OK" }, + ], + }, + stream: false, + } as unknown as ExecuteArgs); + + assert.equal(captured.length, 1, "chat POST should be captured"); + const upstreamMessages = (captured[0] as { messages: Array<{ role: string }> }).messages; + const roles = upstreamMessages.map((m) => m.role); + assert.equal( + roles.some((r) => r === "system" || r === "developer"), + false, + "no system/developer role may reach the upstream payload" + ); + assert.deepEqual(roles, ["user"]); + assert.match( + String((upstreamMessages[0] as { content: string }).content), + /^\[System Instructions\]\n/ + ); + }); + + it("preserves plain user/assistant conversations untouched", async (t) => { + const captured: unknown[] = []; + mockDuckChat(t, captured); + + await new DuckDuckGoWebExecutor().execute({ + model: "gpt-5.4-nano", + body: { + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "bye" }, + ], + }, + stream: false, + } as unknown as ExecuteArgs); + + const upstream = (captured[0] as { messages: Array<{ role: string; content: string }> }) + .messages; + assert.deepEqual( + upstream.map((m) => [m.role, m.content]), + [ + ["user", "hi"], + ["assistant", "hello"], + ["user", "bye"], + ] + ); + }); + + it("shields the executor-injected tool prompt system message too", async (t) => { + const captured: unknown[] = []; + mockDuckChat(t, captured); + + await new DuckDuckGoWebExecutor().execute({ + model: "grole", + body: { + messages: [{ role: "user", content: "list files" }], + tools: [ + { + type: "function", + function: { name: "list_files", description: "lists files", parameters: {} }, + }, + ], + }, + stream: false, + } as unknown as ExecuteArgs); + + const upstream = (captured[0] as { messages: Array<{ role: string; content: string }> }) + .messages; + assert.equal( + upstream.some((m) => m.role === "system" || m.role === "developer"), + false, + "the tool-prompt system message must be folded before dispatch" + ); + assert.match(String(upstream.at(-1)?.content), /list_files/); + }); + }); + describe("integration checks", () => { it("should be properly exported from executor module", async () => { // Import the singleton as well diff --git a/tests/unit/role-normalizer.test.ts b/tests/unit/role-normalizer.test.ts index 5d5eaceabf..af29f5f075 100644 --- a/tests/unit/role-normalizer.test.ts +++ b/tests/unit/role-normalizer.test.ts @@ -110,9 +110,7 @@ test("normalizeSystemRole still strips the system role for pre-5.1 GLM and bare { role: "system", content: "policy" }, { role: "user", content: "ok" }, ]; - const merged = [ - { role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nok" }, - ]; + const merged = [{ role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nok" }]; for (const model of ["glm", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.0", "glm-5.0-turbo"]) { assert.deepEqual( normalizeSystemRole(messages, "openai", model), @@ -186,6 +184,44 @@ test("normalizeRoles composes model, developer and system normalization in order ]); }); +test("normalizeSystemRole folds system/developer into the first user message for DuckDuckGo duck.ai providers (#ddgw)", () => { + // duckchat/v1/chat accepts only user/assistant — a system/developer message + // yields 400 ERR_BAD_REQUEST. Both the registry id and its alias must fold. + const messages = [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Reply with OK" }, + ]; + for (const provider of ["duckduckgo-web", "ddgw"]) { + const result = normalizeSystemRole(messages, provider, "gpt-5.4-nano"); + assert.deepEqual( + result, + [ + { + role: "user", + content: + "[System Instructions]\nYou are a helpful assistant.\n\n[User Message]\nReply with OK", + }, + ], + `expected system folded for provider ${provider}` + ); + } +}); + +test("normalizeRoles keeps folding for every current duck.ai wire model via the ddgw alias (#ddgw)", () => { + const messages = [ + { role: "developer", content: "policy" }, + { role: "user", content: "hello" }, + ]; + for (const model of ["gpt-5.4-mini", "gpt-5.4-nano", "claude-haiku-4-5", "mistral-small-2603"]) { + const result = normalizeRoles(messages, "ddgw", model, "openai"); + assert.deepEqual( + result, + [{ role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nhello" }], + `expected developer folded for ${model}` + ); + } +}); + test("role normalization returns non-arrays unchanged", () => { assert.equal(normalizeDeveloperRole(null, "openai"), null); assert.equal(normalizeModelRole("invalid"), "invalid");