From 25b520c173a58f48583896f1284108dab74820f3 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 16:33:11 +0800 Subject: [PATCH] fix(desktop): keep hosted-search citations on the source origin Do not send citation hostnames to a third-party favicon service. Replace markdown links with citation badges only on #cite= or host+path match. --- apps/desktop/src/lib/hosted-search-ui.ts | 13 ++------- apps/desktop/test/hosted-search-ui.test.mjs | 28 +++++++++++++++++++ .../03-runtime/11-provider-model-system.md | 7 ++++- docs/spec/06-delivery/04-e2e-test-plan.md | 18 ++++++++++++ .../03-runtime/11-provider-model-system.md | 2 +- 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/hosted-search-ui.ts b/apps/desktop/src/lib/hosted-search-ui.ts index deb9d21fbb..ad437edcfb 100644 --- a/apps/desktop/src/lib/hosted-search-ui.ts +++ b/apps/desktop/src/lib/hosted-search-ui.ts @@ -15,11 +15,8 @@ export function hostedSearchHost(url: string): string { export function hostedSearchFaviconCandidates(url: string): string[] { try { const parsed = new URL(url); - const host = parsed.hostname; - const remote = `https://a.favicon.im/${encodeURIComponent(host)}?larger=true`; - return parsed.protocol === "https:" - ? [`${parsed.origin}/favicon.ico`, remote] - : [remote]; + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return []; + return [`${parsed.origin}/favicon.ico`]; } catch { return []; } @@ -90,11 +87,7 @@ export function sourcesForHref( } return matched; } - const direct = sources.filter((source) => urlsReferToSameSource(source.url, href)); - if (direct.length > 0) return direct; - const host = hostedSearchHost(href); - if (!host) return []; - return sources.filter((source) => hostedSearchHost(source.url) === host); + return sources.filter((source) => urlsReferToSameSource(source.url, href)); } export function openChatHttpUrl(url: string): void { diff --git a/apps/desktop/test/hosted-search-ui.test.mjs b/apps/desktop/test/hosted-search-ui.test.mjs index 9c0f4ddda5..6bef489f90 100644 --- a/apps/desktop/test/hosted-search-ui.test.mjs +++ b/apps/desktop/test/hosted-search-ui.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); const { + hostedSearchFaviconCandidates, hostedSearchTitle, rewriteInlineCitationMarkup, sourcesForHref, @@ -61,3 +62,30 @@ test("resolves #cite indices as 1-based source rows", () => { const matched = sourcesForHref("#cite=4", sources); assert.equal(matched[0]?.url, "https://www.bing.com/search?q=tibo"); }); + +test("resolves markdown links only when host and path match a source", () => { + const matched = sourcesForHref("https://example.com/a", sources); + assert.equal(matched.length, 1); + assert.equal(matched[0]?.url, "https://example.com/a"); +}); + +test("does not turn same-host different-path links into citations", () => { + assert.deepEqual(sourcesForHref("https://example.com", sources), []); + assert.deepEqual(sourcesForHref("https://example.com/other", sources), []); +}); + +test("loads favicons only from the source origin", () => { + assert.deepEqual(hostedSearchFaviconCandidates("https://news.example.com/c"), [ + "https://news.example.com/favicon.ico", + ]); + assert.deepEqual(hostedSearchFaviconCandidates("http://example.com/a"), [ + "http://example.com/favicon.ico", + ]); + assert.ok( + hostedSearchFaviconCandidates("https://news.example.com/c").every( + (src) => !src.includes("favicon.im"), + ), + ); + assert.deepEqual(hostedSearchFaviconCandidates("file:///tmp/x"), []); +}); + diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 61c436f166..3dbe7cae91 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -659,7 +659,12 @@ Responses attaches `{ type: "web_search" }`. Chat Completions stays off except xAI (`vendorKey` xai / `api.x.ai`), which attaches `search_parameters`. The Composer globe writes `nativeWebSearchEnabled` (default off). Search results render as a hostedSearch activity row on -the assistant turn and can be expanded to show sources. +the assistant turn and can be expanded to show sources. Inline citation +badges replace a markdown link only when its href is `#cite=N` or matches +a source URL by host and path; a same-host different-path link stays an +ordinary hyperlink. Source favicons load only from that origin's +`/favicon.ico`; the renderer must not send source hostnames to a +third-party favicon service. This is the **universal escape hatch** guaranteeing market coverage beyond native integrations. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 8313bc502c..a91475fdb1 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -13140,6 +13140,24 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Specs:** 04-ux/06-settings-ia, 04-ux/08-component-spec, 04-ux/09-interaction-patterns; ADR turn-process-and-thinking-display. +### E2E-CHAT-hosted-search-citations-stay-local + +- **Preconditions:** An assistant turn with `hostedSearch.sources` that include + `https://example.com/a`, plus markdown that contains `#cite=1`, + `[Read here](https://example.com)`, and `[Same path](https://example.com/a)`. +- **Steps:** Render the turn. Inspect citation badges, remaining hyperlinks, + and favicon image URLs (including failed loads). +- **Expected:** `#cite=1` and the same-path source URL render as citation + badges. The same-host different-path `https://example.com` link stays an + ordinary hyperlink with its original text. Favicon requests go only to each + source origin's `/favicon.ico`; no third-party favicon host is contacted. +- **Automation:** `apps/desktop/test/hosted-search-ui.test.mjs`. +- **Specs:** `03-runtime/11-provider-model-system.md` §16. +- **Acceptance:** Security + C (sessions/transcript). +- **Milestone:** M6+. +- **Status:** Automated unit coverage; renderer E2E still draft. + + ### E2E-RPC-unicode-separators - **Preconditions:** Built host-core, shared package and agent runtime; isolated diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index 3943fa90ab..11bc4c443a 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -576,7 +576,7 @@ UI 可能会显示层级提示,但默认情况下不得硬阻止未知模型 目录条目还可以额外固定模型级 wire API(例如 `api: "openai-responses"`)。存在时它优先于 provider 级 `apiStyle`,因此 `opencode_go` 下的 responses-only 模型会走 Responses adapter 而非 Chat Completions;没有模型级固定时保持 provider 级风格不变。 -Composer 的联网搜索开关按**解析后的线路 API**决定能否挂厂商托管搜索工具,而不是服务显示名或模型 ID:`anthropic_messages` 附加 `web_search_20250305`,Responses 附加 `{ type: "web_search" }`。`chat_completions` 默认不挂,xAI(`vendorKey` xai / `api.x.ai`)除外,走 `search_parameters`。开关写入设置 `nativeWebSearchEnabled`(缺省关闭)。搜索结果作为助手回合的 hostedSearch 活动行展示,可展开看来源。 +Composer 的联网搜索开关按**解析后的线路 API**决定能否挂厂商托管搜索工具,而不是服务显示名或模型 ID:`anthropic_messages` 附加 `web_search_20250305`,Responses 附加 `{ type: "web_search" }`。`chat_completions` 默认不挂,xAI(`vendorKey` xai / `api.x.ai`)除外,走 `search_parameters`。开关写入设置 `nativeWebSearchEnabled`(缺省关闭)。搜索结果作为助手回合的 hostedSearch 活动行展示,可展开看来源。正文超链接仅在 `#cite=N` 或与来源 URL 主机+路径匹配时替换为引用徽章;同主机不同路径保持普通链接。来源 favicon 只从该 origin 的 `/favicon.ico` 加载,不得把来源 hostname 发给第三方 favicon 服务。 ### 16.1 Responses 流终止(pi-ai 补丁)