From 8217c0c117157678c61903b15f4533651c6fd30b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 2 Oct 2026 01:28:59 +1000 Subject: [PATCH 1/3] feat(web-search): support custom Exa-compatible endpoints Read the optional Base URL on each search and reuse the upstream Exa provider for requests and result normalization. Custom endpoints can work without a key; clearing the URL restores the existing built-in routing. Stage the URL in the existing settings card and verify settings writes before clearing drafts. Hide Exa URL drafts while editing DeepSeek. Validated with full tests, typecheck, lint, raw Electron CDP smoke, and four model-driven web_search calls against a local fixture including keyless access, endpoint switching, a configured key, and restart persistence. --- .../resources/dsh/web-search/lib/client.js | 62 ++++++++++++++++--- .../resources/dsh/web-search/lib/index.js | 7 ++- .../src/main/dsh-web-search-client.test.ts | 41 ++++++++++-- .../src/main/dsh-web-search-plugin.test.ts | 39 ++++++++++++ 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/packages/desktop-electron/resources/dsh/web-search/lib/client.js b/packages/desktop-electron/resources/dsh/web-search/lib/client.js index 569c3c928..9507ea6e8 100644 --- a/packages/desktop-electron/resources/dsh/web-search/lib/client.js +++ b/packages/desktop-electron/resources/dsh/web-search/lib/client.js @@ -108,6 +108,9 @@ window.__ModuleLoader__.load({ description: "Which engine answers the agent's searches.", backend: "Search engine", backendHint: "Applies to the next search; no restart needed.", + baseURL: "Base URL", + baseURLHint: "Optional Exa-compatible endpoint. Leave blank to use the built-in service. Requests go to /search.", + apiKeyCustom: "Optional. Enter a key if your custom endpoint requires one.", exa: "Exa", deepseek: "DeepSeek", apiKey: "API key", @@ -129,6 +132,9 @@ window.__ModuleLoader__.load({ description: "agent 搜索网页时用哪个引擎。", backend: "搜索源", backendHint: "下一次搜索即生效,无需重启。", + baseURL: "Base URL", + baseURLHint: "可选的 Exa 兼容服务地址,留空使用内置服务。请求会发到 /search。", + apiKeyCustom: "可选;自定义服务需要密钥时再填写。", exa: "Exa", deepseek: "DeepSeek", apiKey: "API Key", @@ -169,6 +175,7 @@ window.__ModuleLoader__.load({ class CardController { /** `{ backend }`, `{ reset: true }`, or undefined — never two of them. */ backendDraft = undefined + baseURLDraft = undefined keyDraft = undefined saving = false /** The field a save did not land, with the deployment's own words when it had any: `{ field, message }`, or undefined. */ @@ -232,6 +239,10 @@ window.__ModuleLoader__.load({ return this.keyDraft?.backend === this.backend() ? this.keyDraft : undefined } + baseURL() { + return this.baseURLDraft ?? this.scope.getSnapshot().value?.exaBaseURL ?? "" + } + /** * Describe every write a save would perform, in the order it performs them. * @@ -265,14 +276,19 @@ window.__ModuleLoader__.load({ this.backendDraft !== undefined && this.backendDraft.backend !== this.scope.getSnapshot().value?.backend ) { - writes.push({ field: "backend", backend: this.backendDraft.backend }) + writes.push({ field: "backend", value: this.backendDraft.backend }) + } + if (this.backend() === "exa" && this.baseURLDraft !== undefined && + this.baseURLDraft.trim() !== (this.scope.getSnapshot().value?.exaBaseURL ?? "")) { + writes.push({ field: "exaBaseURL", value: this.baseURLDraft.trim() }) } return writes } /** @returns whether any control holds a draft, whether or not it would write. */ staged() { - return this.backendDraft !== undefined || this.stagedKey() !== undefined + return this.backendDraft !== undefined || this.stagedKey() !== undefined || + (this.backend() === "exa" && this.baseURLDraft !== undefined) } /** @returns whether a save would write anything. */ @@ -295,11 +311,12 @@ window.__ModuleLoader__.load({ saving: this.saving, failure: this.failure, backend: this.backend(), + baseURL: this.baseURL(), // Offered while there is an override to remove and removing it is not // already staged — the control is how you stage it, so leaving it up // afterwards would invite pressing it twice for one effect. backendOverridden: Object.hasOwn(user ?? {}, "backend") && this.backendDraft?.reset !== true, - keyless: spec.keyless, + keyless: spec.keyless && this.baseURL().trim() === "", keyText: this.stagedKey()?.text ?? "", keyConfigured: this.held.configured, keyWritable: this.held.writable, @@ -366,6 +383,12 @@ window.__ModuleLoader__.load({ if (this.failure?.field === "key") this.failure = undefined this.publish() }, + editBaseURL: (text) => { + if (this.saving) return + this.baseURLDraft = text + if (this.failure?.field === "exaBaseURL") this.failure = undefined + this.publish() + }, // Stages the reset; it does not perform it. Restoring the default is // not a different kind of act from choosing an engine, so it stages // like one and `save` stays the only path to the deployment — which is @@ -385,6 +408,7 @@ window.__ModuleLoader__.load({ discard: () => { if (this.saving) return this.backendDraft = undefined + this.baseURLDraft = undefined this.keyDraft = undefined this.failure = undefined this.publish() @@ -458,15 +482,17 @@ window.__ModuleLoader__.load({ // did not land is the same lie as an engine that silently did not. const made = write.reset === true - ? await this.commit("backend", () => this.scope.unset("backend")) - : await this.commit("backend", () => this.scope.set("backend", write.backend)) + ? await this.commit(write.field, () => this.scope.unset(write.field)) + : await this.commit(write.field, () => this.scope.set(write.field, write.value)) const wrote = made && (write.reset === true - ? !Object.hasOwn(this.scope.getSnapshot().user ?? {}, "backend") - : this.scope.getSnapshot().user?.backend === write.backend) - if (wrote) this.backendDraft = undefined - else if (made) this.failure = { field: "backend" } + ? !Object.hasOwn(this.scope.getSnapshot().user ?? {}, write.field) + : this.scope.getSnapshot().user?.[write.field] === write.value) + if (wrote) { + if (write.field === "backend") this.backendDraft = undefined + else this.baseURLDraft = undefined + } else if (made) this.failure = { field: write.field } } await this.readCredential() } finally { @@ -614,6 +640,21 @@ window.__ModuleLoader__.load({ label: t("backend"), labelledControl: true, }), + state.backend === "exa" ? h(Field, { + control: h("input", { + autoComplete: "off", + className: "pawwork-websearch-input", + disabled: disabled || state.saving, + id: "pawwork-websearch-base-url", + onChange: (event) => props.editBaseURL(event.target.value), + placeholder: "https://api.exa.ai", + type: "url", + value: state.baseURL, + }), + hint: t("baseURLHint"), + id: "pawwork-websearch-base-url", + label: t("baseURL"), + }) : null, h(Field, { badges: keyBadge, control: h("input", { @@ -628,7 +669,8 @@ window.__ModuleLoader__.load({ }), hint: state.keyConfigured ? t("apiKeyHint") - : state.keyless ? t("apiKeyUnsetFree") : t("apiKeyUnsetRequired"), + : state.backend === "exa" && !state.keyless ? t("apiKeyCustom") + : state.keyless ? t("apiKeyUnsetFree") : t("apiKeyUnsetRequired"), id: "pawwork-websearch-key", label: t("apiKey"), }), diff --git a/packages/desktop-electron/resources/dsh/web-search/lib/index.js b/packages/desktop-electron/resources/dsh/web-search/lib/index.js index 69365cea8..6af149671 100644 --- a/packages/desktop-electron/resources/dsh/web-search/lib/index.js +++ b/packages/desktop-electron/resources/dsh/web-search/lib/index.js @@ -53,6 +53,7 @@ const DEEPSEEK_KEY_MESSAGE = export const Config = z.object({ backend: z.union(['exa', 'deepseek']).default('exa').volatile(), + exaBaseURL: z.string().default('').volatile(), exaApiKeyEnv: z.string().role('credential-ref').default(DEFAULT_EXA_API_KEY_ENV).volatile(), deepseekApiKeyEnv: z.string().role('credential-ref').default(DEFAULT_DEEPSEEK_API_KEY_ENV).volatile(), }); @@ -99,10 +100,11 @@ async function resolveKey(ctx, ref) { */ async function searchExa(ctx, config, request, signal) { const apiKey = await resolveKey(ctx, resolveRef(config.exaApiKeyEnv, DEFAULT_EXA_API_KEY_ENV)); - if (apiKey.length > 0) { + const baseURL = (config.exaBaseURL ?? '').trim().replace(/\/+$/, ''); + if (baseURL.length > 0 || apiKey.length > 0) { return new ExaSearchProvider({ apiKey, - baseURL: 'https://api.exa.ai', + baseURL: baseURL || 'https://api.exa.ai', searchType: 'auto', highlightsPerResult: 1, }).search(request, signal); @@ -200,6 +202,7 @@ export class PawWorkSearchProvider { export function apply(ctx, config) { ctx.web.registerSearchProvider(new PawWorkSearchProvider(ctx, () => ({ backend: config.backend.get(), + exaBaseURL: config.exaBaseURL.get(), exaApiKeyEnv: config.exaApiKeyEnv.get(), deepseekApiKeyEnv: config.deepseekApiKeyEnv.get(), }))); diff --git a/packages/desktop-electron/src/main/dsh-web-search-client.test.ts b/packages/desktop-electron/src/main/dsh-web-search-client.test.ts index 8068e339b..4d393aeb8 100644 --- a/packages/desktop-electron/src/main/dsh-web-search-client.test.ts +++ b/packages/desktop-electron/src/main/dsh-web-search-client.test.ts @@ -79,6 +79,7 @@ function loadPlugin() { type CardActions = { discard: () => void editKey: (text: string) => void + editBaseURL: (text: string) => void hooks: { webSearchCard: { getSnapshot: () => Record } } resetBackend: () => Promise | void save: () => Promise @@ -190,6 +191,37 @@ function textOf(card: (props: Record) => unknown, injected: Car } describe("PawWork DSH web search card", () => { + test("stages, discards, saves and clears the Exa endpoint", async () => { + const { card, injected, scope } = cardOf() + injected.editBaseURL(" http://127.0.0.1:4479/ ") + expect(scope.set).not.toHaveBeenCalled() + expect(stateOf(injected).dirty).toBe(true) + injected.discard() + expect(stateOf(injected).baseURL).toBe("") + injected.editBaseURL(" http://127.0.0.1:4479/ ") + await injected.save() + expect(scope.set).toHaveBeenLastCalledWith("exaBaseURL", "http://127.0.0.1:4479/") + expect(stateOf(injected).dirty).toBe(false) + expect(textOf(card, injected)).not.toContain("keylessBadge") + injected.editBaseURL("") + await injected.save() + expect(scope.set).toHaveBeenLastCalledWith("exaBaseURL", "") + }) + + test("an Exa endpoint draft is hidden and unwritten while DeepSeek is selected", async () => { + const { card, injected, scope } = cardOf() + injected.editBaseURL("http://127.0.0.1:4479") + injected.selectBackend("deepseek") + expect(visit(render(card, injected)).find((element) => element.props.id === "pawwork-websearch-base-url")).toBeUndefined() + await injected.save() + expect(scope.set).toHaveBeenCalledTimes(1) + expect(scope.set).toHaveBeenCalledWith("backend", "deepseek") + injected.selectBackend("exa") + expect(stateOf(injected).baseURL).toBe("http://127.0.0.1:4479") + await injected.save() + expect(scope.set).toHaveBeenLastCalledWith("exaBaseURL", "http://127.0.0.1:4479") + }) + // The card's whole surface is one module the Host evaluates, and a throw at // load removes it with nothing but a loader line in a console no user reads. // This file is the only thing that evaluates it; lint parses it but does not @@ -318,7 +350,7 @@ describe("PawWork DSH web search card", () => { // A write that threw used to escape `save` before it could clear `saving`, // leaving both buttons disabled for the rest of the session with the drafts // trapped behind them. - test("a throwing write leaves the card usable", async () => { + test.each(["backend", "exaBaseURL"])("a throwing %s write leaves the card usable", async (field) => { const logged = vi.spyOn(console, "error").mockImplementation(() => {}) try { const { card, injected } = cardOf({ @@ -327,15 +359,16 @@ describe("PawWork DSH web search card", () => { }, }) - injected.selectBackend("deepseek") + if (field === "backend") injected.selectBackend("deepseek") + else injected.editBaseURL("http://127.0.0.1:4479") await injected.save() const state = stateOf(injected) expect(state.saving).toBe(false) - expect(state.failure).toEqual({ field: "backend" }) + expect(state.failure).toEqual({ field }) // Nothing answered, so there are no words of anyone else's to show. expect(textOf(card, injected)).toContain("saveFailedApp") - expect(state.backend).toBe("deepseek") + expect(state.dirty).toBe(true) } finally { logged.mockRestore() } diff --git a/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts b/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts index e5f4294bc..b40bf56bb 100644 --- a/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts +++ b/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts @@ -170,6 +170,45 @@ describe("PawWork DSH web search plugin", () => { }) describe("PawWork search engine selection", () => { + test.each([undefined, "proxy-key"])("custom Exa endpoints accept an optional key (%s) and normalize results", async (key) => { + const fetch = vi.fn(async () => Response.json({ results: [ + { url: "https://example.com", title: "Result", highlights: ["Excerpt"], publishedDate: "2026-10-02" }, + { url: "https://example.com/empty", highlights: [] }, + ] })) + vi.stubGlobal("fetch", fetch) + try { + const result = await providerFor({ exaBaseURL: " http://127.0.0.1:4479/proxy/ " }, key) + .search({ query: "custom search", maxResults: 2 }) + expect(fetch.mock.calls[0]).toEqual(["http://127.0.0.1:4479/proxy/search", expect.objectContaining({ + body: expect.stringContaining('"query":"custom search"'), + headers: expect.objectContaining({ authorization: `Bearer ${key ?? ""}` }), + })]) + expect(result).toEqual({ sources: [{ url: "https://example.com", title: "Result", snippet: "Excerpt", publishedAt: "2026-10-02" }], truncated: false }) + } finally { + vi.unstubAllGlobals() + } + }) + + test("reads a changed Exa endpoint on the next search, including clearing it", async () => { + let baseURL = "http://127.0.0.1:4479" + const config = { ...Config({}), exaBaseURL: { get: () => baseURL } } + let registered: PawWorkSearchProvider | undefined + apply({ ...contextWith(), web: { registerSearchProvider: (provider: PawWorkSearchProvider) => (registered = provider) } }, config) + const urls: string[] = [] + vi.stubGlobal("fetch", async (url: string) => { + urls.push(String(url)) + return url.includes("mcp.exa.ai") ? exaResponse(block()) : Response.json({ results: [] }) + }) + try { + await registered!.search({ query: "custom" }) + baseURL = " " + await registered!.search({ query: "default" }) + expect(urls).toEqual(["http://127.0.0.1:4479/search", "https://mcp.exa.ai/mcp"]) + } finally { + vi.unstubAllGlobals() + } + }) + // The product promise: a fresh install searches before anyone configures a // key. A request that grew a credential parameter would be scoped to a key // nobody has. From b3053cd2092d388f9cf886d5780eaa586f8177f9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 2 Oct 2026 10:26:08 +1000 Subject: [PATCH 2/3] fix(web-search): preserve endpoint and credential identity during saves Reject malformed Exa destinations and credential-bearing remote HTTP searches while retaining loopback and keyless proxies. Report endpoint errors before saving a credential. Store each replacement Exa key under a new reference, then activate the reference and endpoint with one revision-fenced settings mutation. Failed saves retain the previous pair and drafts; in-flight searches keep their original credential. Retain previous references because searches may still be resolving them. Verified 65 focused tests, 122 Node tests, typecheck, lint, build, raw Electron CDP smoke, and real Host refusal/retry/restart tests with model-driven web_search requests. The broader local Vitest run had 583 passes and four existing transitive dsh-web-app resolution failures, also present on main. --- .../resources/dsh/web-search/lib/client.js | 178 +++++++----------- .../resources/dsh/web-search/lib/index.js | 13 ++ .../src/main/dsh-web-search-client.test.ts | 145 +++++++++++++- .../src/main/dsh-web-search-plugin.test.ts | 27 +++ 4 files changed, 249 insertions(+), 114 deletions(-) diff --git a/packages/desktop-electron/resources/dsh/web-search/lib/client.js b/packages/desktop-electron/resources/dsh/web-search/lib/client.js index 9507ea6e8..35695aece 100644 --- a/packages/desktop-electron/resources/dsh/web-search/lib/client.js +++ b/packages/desktop-electron/resources/dsh/web-search/lib/client.js @@ -110,6 +110,8 @@ window.__ModuleLoader__.load({ backendHint: "Applies to the next search; no restart needed.", baseURL: "Base URL", baseURLHint: "Optional Exa-compatible endpoint. Leave blank to use the built-in service. Requests go to /search.", + baseURLInvalid: "Enter an absolute HTTP or HTTPS URL without credentials, a query or a fragment.", + baseURLInsecure: "An endpoint receiving an API key must use HTTPS. HTTP is allowed only on loopback.", apiKeyCustom: "Optional. Enter a key if your custom endpoint requires one.", exa: "Exa", deepseek: "DeepSeek", @@ -134,6 +136,8 @@ window.__ModuleLoader__.load({ backendHint: "下一次搜索即生效,无需重启。", baseURL: "Base URL", baseURLHint: "可选的 Exa 兼容服务地址,留空使用内置服务。请求会发到 /search。", + baseURLInvalid: "请输入完整的 HTTP 或 HTTPS 地址,不含用户名、密码、查询参数或片段。", + baseURLInsecure: "接收 API Key 的服务必须使用 HTTPS;只有本机回环地址可以使用 HTTP。", apiKeyCustom: "可选;自定义服务需要密钥时再填写。", exa: "Exa", deepseek: "DeepSeek", @@ -152,25 +156,9 @@ window.__ModuleLoader__.load({ } /** - * The card's staged form over the `pawwork-web-search` section. - * - * Two controls, staged together so one save covers both: the backend, which - * lives in the section, and the key for whichever backend is selected, which - * does not — a secret never rides a settings response, so the card learns only - * whether one is configured and writes it through the credentials domain. - * - * One key draft, and it carries the engine it was typed under. An API key has - * no meaning apart from the engine it authenticates, so a staged key the card - * is not showing must never be written: type an Exa key, switch the engine, - * save, and a secret the user believes they abandoned reaches a second - * vendor. `stagedKey` answers with the draft only while its engine is the one - * on screen, which keeps what a save can write equal to what the card - * displays. - * - * For the same reason `pendingWrites` is the single description of the work a - * save has to do: the button's enabled state and the writes themselves read - * it, so "there is something to save" cannot mean one thing to the user and - * another to the code. + * Staged settings and credentials for the selected search engine. + * A key draft remains tied to the engine it was typed under, so switching + * engines hides it and excludes it from Save without discarding it. */ class CardController { /** `{ backend }`, `{ reset: true }`, or undefined — never two of them. */ @@ -187,8 +175,10 @@ window.__ModuleLoader__.load({ /** * @param scope - the bound settings scope for this card's namespace. * @param credentials - the credential face for the reference the section names. + * @param t - the section translator for form feedback. */ - constructor(scope, credentials) { + constructor(scope, credentials, t) { + this.t = t this.scope = scope this.credentials = credentials this.store = createSnapshotStore(this.projection()) @@ -243,26 +233,7 @@ window.__ModuleLoader__.load({ return this.baseURLDraft ?? this.scope.getSnapshot().value?.exaBaseURL ?? "" } - /** - * Describe every write a save would perform, in the order it performs them. - * - * The key goes first. The two stores cannot commit together — the section - * and the credential are separate authorities — so the order decides what a - * half-finished save leaves behind, and a key written before the engine - * moves means the engine never runs a moment without the credential it was - * chosen for. Should the engine write then fail, the key is still filed - * under the vendor the user was looking at when they typed it. - * - * Its reference comes from the draft's own engine rather than the current - * selection: deriving the destination from anything that can move between - * staging and writing is exactly what sent one vendor's secret to another. - * - * What counts as a write is decided once, here, and "staged" is not it: a - * key that is only whitespace and an engine already in force are both - * nothing to save, and the Save button agrees because it asks this rather - * than deciding for itself. - * @returns the staged writes, empty when there is nothing to save. - */ + /** @returns the staged writes shared by Save and its enabled state. */ pendingWrites() { const staged = this.stagedKey() const writes = [] @@ -417,82 +388,77 @@ window.__ModuleLoader__.load({ } } - /** - * Run one settings write and report whether it completed without throwing. - * - * The scope reports that a write did not land, never why, so a throw is the - * only signal that the call itself could not be made. - * @param field - the field this write belongs to, for the failure report. - * @param write - performs the write; its resolved value is not inspected. - * @returns whether the write completed without throwing. - */ - async commit(field, write) { - try { - await write() - return true - } catch (failure) { - console.error(`[pawwork-web-search] the ${field} write could not be made:`, failure) - this.failure = { field } - return false + /** Localized form feedback; the Host still checks every search destination. */ + endpointFailure() { + if (this.backend() !== "exa" || this.baseURL().trim() === "") return undefined + let url + try { url = new URL(this.baseURL().trim()) } catch { return "baseURLInvalid" } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + return "baseURLInvalid" + } + const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname) + if (url.protocol === "http:" && !loopback && (this.held.configured || this.stagedKey()?.text.trim())) { + return "baseURLInsecure" } } - /** - * The card's only path to the deployment. - * - * Every control stages; this writes. That is why `saving` alone is enough - * to serialize the card — there is no second writer to serialize against. - * - * The Host decides whether a value landed, so the outcome is read back - * rather than predicted, and a field that did not land keeps its draft. - * The two stores cannot commit together, so the writes are reported - * independently: rolling the engine back because the key failed would be a - * third write that can fail too, contradicting a Host that already holds - * the new engine. - */ + /** Store secrets separately, then activate the settings in one mutation. */ async save() { if (this.saving) return const writes = this.pendingWrites() if (writes.length === 0) return + const problem = this.endpointFailure() + if (problem !== undefined) { + this.failure = { field: "exaBaseURL", message: this.t(problem) } + this.publish() + return + } + const revision = this.scope.getSnapshot().revision + const key = writes.find((write) => write.field === "key") + const exaKey = key !== undefined && this.backend() === "exa" + // Never overwrite a reference an older search may still be resolving. + if (exaKey) key.ref = `PAWWORK_EXA_${crypto.randomUUID().replaceAll("-", "_")}` + const settings = writes.filter((write) => write.field !== "key") + if (exaKey) { + settings.push({ field: "exaApiKeyEnv", value: key.ref }) + if (!settings.some((write) => write.field === "exaBaseURL")) { + settings.push({ field: "exaBaseURL", value: this.baseURL().trim() }) + } + } this.saving = true this.failure = undefined this.publish() try { - for (const write of writes) { - if (write.field === "key") { - // The deployment's answer decides, and `configured` cannot stand in - // for it: that flag is already true whenever a key was set before, - // so a rejected rotation would read as a successful one. - const failed = await this.credentials.store(write.ref, write.value) - if (failed !== undefined) { - // The key goes first so the engine never runs a moment without - // the credential it was chosen for — which is exactly what - // carrying on would produce. Any pending engine write selects - // the engine this key was typed under, so it stays staged and - // Save retries both rather than moving the user onto an engine - // whose key the deployment just refused. - this.failure = { field: "key", ...failed } - break - } - this.keyDraft = undefined - continue + if (key !== undefined) { + const failed = await this.credentials.store(key.ref, key.value) + if (failed !== undefined) { + this.failure = { field: "key", ...failed } + return + } + if (!exaKey) this.keyDraft = undefined + } + if (settings.length > 0) { + let made = false + try { + await this.scope.mutate(settings.map((write) => write.reset === true + ? { op: "unset", path: [write.field] } + : { op: "set", path: [write.field], value: write.value }), revision) + made = true + } catch (failure) { + console.error("[pawwork-web-search] the settings mutation could not be made:", failure) + } + const user = this.scope.getSnapshot().user ?? {} + const missing = settings.find((write) => write.reset === true + ? Object.hasOwn(user, write.field) + : user[write.field] !== write.value) + if (made && missing === undefined) { + if (settings.some((write) => write.field === "backend")) this.backendDraft = undefined + if (settings.some((write) => write.field === "exaBaseURL")) this.baseURLDraft = undefined + if (exaKey) this.keyDraft = undefined + } else { + const field = (missing ?? settings[0]).field + this.failure = { field: field === "exaApiKeyEnv" ? "key" : field } } - // Read back either way. A Host that accepts the call without moving - // the value is the case this exists for, and a reset that silently - // did not land is the same lie as an engine that silently did not. - const made = - write.reset === true - ? await this.commit(write.field, () => this.scope.unset(write.field)) - : await this.commit(write.field, () => this.scope.set(write.field, write.value)) - const wrote = - made && - (write.reset === true - ? !Object.hasOwn(this.scope.getSnapshot().user ?? {}, write.field) - : this.scope.getSnapshot().user?.[write.field] === write.value) - if (wrote) { - if (write.field === "backend") this.backendDraft = undefined - else this.baseURLDraft = undefined - } else if (made) this.failure = { field: write.field } } await this.readCredential() } finally { @@ -700,12 +666,12 @@ window.__ModuleLoader__.load({ function apply(ctx) { ctx.effect(() => ctx.locale.register(NS, { en, zh }), "pawwork-web-search: card dictionaries") - const card = new CardController(ctx.configForms.get(NS), credentialFace(ctx)) + const t = ctx.locale.bind(NS) + const card = new CardController(ctx.configForms.get(NS), credentialFace(ctx), t) ctx.effect( () => ctx.remote.$on("credentials/reference-updated", (ref) => card.refreshCredential(ref)), "pawwork-web-search: credential invalidations", ) - const t = ctx.locale.bind(NS) ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({ name: "plugins.item", id: NS, diff --git a/packages/desktop-electron/resources/dsh/web-search/lib/index.js b/packages/desktop-electron/resources/dsh/web-search/lib/index.js index 6af149671..f90e3d419 100644 --- a/packages/desktop-electron/resources/dsh/web-search/lib/index.js +++ b/packages/desktop-electron/resources/dsh/web-search/lib/index.js @@ -101,6 +101,19 @@ async function resolveKey(ctx, ref) { async function searchExa(ctx, config, request, signal) { const apiKey = await resolveKey(ctx, resolveRef(config.exaApiKeyEnv, DEFAULT_EXA_API_KEY_ENV)); const baseURL = (config.exaBaseURL ?? '').trim().replace(/\/+$/, ''); + if (baseURL.length > 0) { + let url; + try { url = new URL(baseURL); } catch { + throw new WebError('The Exa Base URL must be an absolute HTTP or HTTPS URL.', 'WEB_PROVIDER_ERROR'); + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new WebError('The Exa Base URL must be an HTTP or HTTPS URL without credentials, a query or a fragment.', 'WEB_PROVIDER_ERROR'); + } + const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname); + if (apiKey.length > 0 && url.protocol === 'http:' && !loopback) { + throw new WebError('An Exa endpoint receiving an API key must use HTTPS (HTTP is allowed only on loopback).', 'WEB_PROVIDER_ERROR'); + } + } if (baseURL.length > 0 || apiKey.length > 0) { return new ExaSearchProvider({ apiKey, diff --git a/packages/desktop-electron/src/main/dsh-web-search-client.test.ts b/packages/desktop-electron/src/main/dsh-web-search-client.test.ts index 4d393aeb8..ca5b0ae0d 100644 --- a/packages/desktop-electron/src/main/dsh-web-search-client.test.ts +++ b/packages/desktop-electron/src/main/dsh-web-search-client.test.ts @@ -1,4 +1,6 @@ import { resolve } from "node:path" +import { randomUUID } from "node:crypto" +import { PawWorkSearchProvider } from "../../resources/dsh/web-search/lib/index.js" import { describe, expect, test, vi } from "vitest" import { loadDshClientModule } from "./dsh-client-module.testing" import { dshRemoteContract, fakeDshRemote, type RemoteAnswer } from "./dsh-remote-contract.testing" @@ -62,7 +64,7 @@ function fakeStore(value: unknown) { */ function loadPlugin() { // The host console stands in for the page's, so the card's logs reach the test. - const definition = loadDshClientModule(clientEntry, { console, document: fakeDocument() }) + const definition = loadDshClientModule(clientEntry, { console, document: fakeDocument(), URL, crypto: { randomUUID } }) return { definition, plugin: definition.factory((module: string) => { @@ -91,7 +93,7 @@ type CardOptions = { /** Answers `credentials.describe`, the way the deployment would. */ describe?: () => RemoteAnswer | Promise /** Answers `credentials.set`, the way the deployment would. */ - setCredential?: () => RemoteAnswer | Promise + setCredential?: (ref: string, value: string) => RemoteAnswer | Promise set?: () => Promise unset?: () => Promise unsetIgnored?: boolean @@ -103,6 +105,7 @@ function cardOf(options: CardOptions = {}) { const snapshot: Record = { status: "ready", writable: true, + revision: 1, // What the Host describes for a namespace whose defaults nothing overrode: // the reference names reach the card in both layers, never as a constant. value: { backend: "exa", exaApiKeyEnv: "EXA_API_KEY", deepseekApiKeyEnv: "DEEPSEEK_API_KEY" }, @@ -131,10 +134,26 @@ function cardOf(options: CardOptions = {}) { snapshot.value = { ...(snapshot.value as object), [key]: (snapshot.base as Record)?.[key] } }), } + const mutate = vi.fn(async (ops: Array<{ op: string; path: string[]; value?: unknown }>, revision?: number) => { + if (revision !== snapshot.revision) return false + const previous = { ...snapshot } + try { + for (const op of ops) { + if (op.op === "set") await scope.set(op.path[0], op.value) + else await scope.unset(op.path[0]) + } + snapshot.revision = Number(snapshot.revision) + 1 + return true + } catch (error) { + Object.assign(snapshot, previous) + throw error + } + }) + Object.assign(scope, { mutate }) // Spied, not replaced: the double's parsing is what refuses a bad call. const namespace = fakeDshRemote(CREDENTIALS, { describe: async () => options.describe?.() ?? { ok: true, value: {} }, - set: async () => options.setCredential?.() ?? { ok: true, value: undefined }, + set: async (ref: string, value: string) => options.setCredential?.(ref, value) ?? { ok: true, value: undefined }, }) const credentials = { describe: vi.fn(namespace.describe), set: vi.fn(namespace.set) } plugin.apply({ @@ -166,7 +185,7 @@ function cardOf(options: CardOptions = {}) { }, }) const injected = (registrations[0]?.inject as () => CardActions)() - return { card: card!, credentials, definition, injected, plugin, registrations, scope } + return { card: card!, credentials, definition, injected, plugin, registrations, scope: { ...scope, mutate } } } /** Render the card with a translator that echoes locale keys. */ @@ -191,6 +210,115 @@ function textOf(card: (props: Record) => unknown, injected: Car } describe("PawWork DSH web search card", () => { + test("invalid endpoint drafts stay visible and cannot save a key or settings", async () => { + const { injected, scope, credentials } = cardOf() + injected.editBaseURL("not-a-url") + injected.editKey("secret") + await injected.save() + expect(scope.set).not.toHaveBeenCalled() + expect(credentials.set).not.toHaveBeenCalled() + expect(stateOf(injected)).toMatchObject({ baseURL: "not-a-url", keyText: "secret", failure: { field: "exaBaseURL" } }) + injected.editBaseURL("http://proxy.example.com") + await injected.save() + expect(credentials.set).not.toHaveBeenCalled() + expect(stateOf(injected).failure).toEqual({ field: "exaBaseURL", message: "baseURLInsecure" }) + injected.editBaseURL("https://proxy.example.com") + await injected.save() + expect(stateOf(injected)).toMatchObject({ dirty: false, failure: undefined }) + }) + + test("an in-flight search keeps its endpoint and credential through a successful save", async () => { + const keys = new Map([["EXA_API_KEY", "old-secret"]]) + let release!: () => void + const resolving = new Promise((resolve) => { release = resolve }) + const { injected, scope } = cardOf({ + section: { value: { backend: "exa", exaBaseURL: "https://old.example.com", exaApiKeyEnv: "EXA_API_KEY" } }, + setCredential: (ref, value) => { keys.set(ref, value); return { ok: true } }, + }) + const provider = new PawWorkSearchProvider({ get: () => ({ resolve: async (ref: string) => { + await resolving + return { value: keys.get(ref) } + } }) }, () => scope.getSnapshot().value) + const fetch = vi.fn(async () => Response.json({ results: [] })) + vi.stubGlobal("fetch", fetch) + try { + const searching = provider.search({ query: "started before save" }) + injected.editBaseURL("https://new.example.com") + injected.editKey("new-secret") + await injected.save() + release() + await searching + expect(fetch).toHaveBeenCalledWith("https://old.example.com/search", expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer old-secret" }), + })) + fetch.mockClear() + await provider.search({ query: "started after save" }) + expect(fetch).toHaveBeenCalledWith("https://new.example.com/search", expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer new-secret" }), + })) + } finally { + release() + vi.unstubAllGlobals() + } + }) + + test("an external settings edit during a credential write refuses stale activation", async () => { + let release!: (answer: RemoteAnswer) => void + const { injected, scope } = cardOf({ setCredential: () => new Promise((resolve) => { release = resolve }) }) + injected.editBaseURL("https://new.example.com") + injected.editKey("new-secret") + const saving = injected.save() + await scope.mutate([{ op: "set", path: ["exaBaseURL"], value: "https://other.example.com" }], 1) + release({ ok: true }) + await saving + expect(scope.getSnapshot().value).toMatchObject({ exaBaseURL: "https://other.example.com", exaApiKeyEnv: "EXA_API_KEY" }) + expect(stateOf(injected)).toMatchObject({ dirty: true, keyText: "new-secret", failure: { field: "exaBaseURL" } }) + }) + + test("a failed endpoint and key save keeps searches on the previous credential and retries the pair", async () => { + const keys = new Map([["EXA_API_KEY", "old-secret"]]) + let reject = true + const { injected, scope } = cardOf({ + section: { value: { backend: "exa", exaBaseURL: "https://old.example.com", exaApiKeyEnv: "EXA_API_KEY" } }, + setCredential: (ref, value) => { keys.set(ref, value); return { ok: true } }, + set: async () => { if (reject) throw new Error("settings write refused") }, + }) + const provider = new PawWorkSearchProvider({ get: () => ({ resolve: async (ref: string) => ({ value: keys.get(ref) }) }) }, + () => scope.getSnapshot().value) + const sent: Array<{ url: string; key: string }> = [] + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + sent.push({ url, key: (init.headers as Record).authorization }) + return Response.json({ results: [] }) + }) + const logged = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + injected.editBaseURL("https://new.example.com") + injected.editKey("new-secret") + await injected.save() + await provider.search({ query: "after failure" }) + expect(stateOf(injected)).toMatchObject({ dirty: true, keyText: "new-secret" }) + injected.discard() + await provider.search({ query: "after discard" }) + reject = false + injected.editBaseURL("https://new.example.com") + injected.editKey("new-secret") + await injected.save() + await provider.search({ query: "after retry" }) + expect(scope.mutate).toHaveBeenLastCalledWith(expect.arrayContaining([ + { op: "set", path: ["exaBaseURL"], value: "https://new.example.com" }, + { op: "set", path: ["exaApiKeyEnv"], value: expect.stringMatching(/^PAWWORK_EXA_/) }, + ]), 1) + expect(sent).toEqual([ + { url: "https://old.example.com/search", key: "Bearer old-secret" }, + { url: "https://old.example.com/search", key: "Bearer old-secret" }, + { url: "https://new.example.com/search", key: "Bearer new-secret" }, + ]) + } finally { + logged.mockRestore() + vi.unstubAllGlobals() + } + }) + test("stages, discards, saves and clears the Exa endpoint", async () => { const { card, injected, scope } = cardOf() injected.editBaseURL(" http://127.0.0.1:4479/ ") @@ -313,7 +441,7 @@ describe("PawWork DSH web search card", () => { expect(stateOf(injected).keyText).toBe("exa-secret") await injected.save() - expect(credentials.set).toHaveBeenCalledWith("EXA_API_KEY", "exa-secret") + expect(credentials.set).toHaveBeenCalledWith(expect.stringMatching(/^PAWWORK_EXA_/), "exa-secret") }) // The Save button and the writes have to agree on what counts as a change. They @@ -636,14 +764,15 @@ describe("PawWork DSH web search card", () => { // A reference the two halves read differently has the card describing and // writing one name while the search resolves another, so the card applies the // Host's whole grammar and not just its blank check. - test("an unresolvable credential reference falls back the way the Host half does", async () => { + test("a key can be configured when the previous Exa reference is unresolvable", async () => { for (const ref of ["", " ", "my-key", "1KEY", "EXA.API.KEY"]) { - const { credentials, injected } = cardOf({ section: { value: { backend: "exa", exaApiKeyEnv: ref } } }) + const { credentials, injected, scope } = cardOf({ section: { value: { backend: "exa", exaApiKeyEnv: ref } } }) injected.editKey("exa-secret") await injected.save() - expect(credentials.set).toHaveBeenCalledWith("EXA_API_KEY", "exa-secret") + expect(credentials.set).toHaveBeenCalledWith(expect.stringMatching(/^PAWWORK_EXA_/), "exa-secret") + expect(scope.getSnapshot().value).toMatchObject({ exaApiKeyEnv: credentials.set.mock.calls[0][0] }) } }) diff --git a/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts b/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts index b40bf56bb..24182a315 100644 --- a/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts +++ b/packages/desktop-electron/src/main/dsh-web-search-plugin.test.ts @@ -170,6 +170,33 @@ describe("PawWork DSH web search plugin", () => { }) describe("PawWork search engine selection", () => { + test("invalid hand-edited Exa endpoints fail before a network request", async () => { + const fetch = vi.fn(async () => Response.json({ results: [] })) + vi.stubGlobal("fetch", fetch) + try { + for (const exaBaseURL of ["not-a-url", "file:///tmp/search", "https://user:secret@proxy.example.com", "https://proxy.example.com?token=secret"]) { + await expect(providerFor({ exaBaseURL }).search({ query: "test" })).rejects.toMatchObject({ code: "WEB_PROVIDER_ERROR" }) + } + expect(fetch).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + test("never sends a credential to a remote plaintext Exa endpoint", async () => { + const fetch = vi.fn(async () => Response.json({ results: [] })) + vi.stubGlobal("fetch", fetch) + try { + await expect(providerFor({ exaBaseURL: "http://proxy.example.com" }, "secret") + .search({ query: "private query" })).rejects.toMatchObject({ code: "WEB_PROVIDER_ERROR" }) + expect(fetch).not.toHaveBeenCalled() + await providerFor({ exaBaseURL: "http://proxy.example.com" }).search({ query: "keyless" }) + expect(fetch).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) + test.each([undefined, "proxy-key"])("custom Exa endpoints accept an optional key (%s) and normalize results", async (key) => { const fetch = vi.fn(async () => Response.json({ results: [ { url: "https://example.com", title: "Result", highlights: ["Excerpt"], publishedDate: "2026-10-02" }, From fc71230b439a85a72984c0327c35568285e80f02 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 2 Oct 2026 10:30:43 +1000 Subject: [PATCH 3/3] chore(deps): refresh devalue to clear new high advisories Update only the Astro transitive lockfile resolution from devalue 5.9.0 to 5.9.4 within its existing ^5.8.1 range. This clears GHSA-j22f-vq7h-c4qm, GHSA-mcm9-63f2-9j32, and GHSA-x5rw-q4pp-hg5g, which newly blocked the PR audit check. Frozen install, high audit, site check and site build pass. The complete desktop suite passes through the pnpm-generated launcher: 588 Vitest, 122 Node and 10 label tests. Earlier direct-JS Vitest invocation omitted that launcher NODE_PATH; its four transitive-resolution failures were a test invocation issue, not repository defects. --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa49debe..3d86484a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5006,8 +5006,8 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - devalue@5.9.0: - resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -12619,7 +12619,7 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 2.0.1 - devalue: 5.9.0 + devalue: 5.9.4 diff: 9.0.0 dset: 3.1.4 es-module-lexer: 2.3.2 @@ -13071,7 +13071,7 @@ snapshots: detect-node@2.1.0: optional: true - devalue@5.9.0: {} + devalue@5.9.4: {} devlop@1.1.0: dependencies: