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..35695aece 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,11 @@ 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.", + 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", apiKey: "API key", @@ -129,6 +134,11 @@ window.__ModuleLoader__.load({ description: "agent 搜索网页时用哪个引擎。", backend: "搜索源", backendHint: "下一次搜索即生效,无需重启。", + baseURL: "Base URL", + baseURLHint: "可选的 Exa 兼容服务地址,留空使用内置服务。请求会发到 /search。", + baseURLInvalid: "请输入完整的 HTTP 或 HTTPS 地址,不含用户名、密码、查询参数或片段。", + baseURLInsecure: "接收 API Key 的服务必须使用 HTTPS;只有本机回环地址可以使用 HTTP。", + apiKeyCustom: "可选;自定义服务需要密钥时再填写。", exa: "Exa", deepseek: "DeepSeek", apiKey: "API Key", @@ -146,29 +156,14 @@ 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. */ 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. */ @@ -180,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()) @@ -232,26 +229,11 @@ window.__ModuleLoader__.load({ return this.keyDraft?.backend === this.backend() ? this.keyDraft : undefined } - /** - * 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. - */ + baseURL() { + return this.baseURLDraft ?? this.scope.getSnapshot().value?.exaBaseURL ?? "" + } + + /** @returns the staged writes shared by Save and its enabled state. */ pendingWrites() { const staged = this.stagedKey() const writes = [] @@ -265,14 +247,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 +282,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 +354,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 +379,7 @@ window.__ModuleLoader__.load({ discard: () => { if (this.saving) return this.backendDraft = undefined + this.baseURLDraft = undefined this.keyDraft = undefined this.failure = undefined this.publish() @@ -393,80 +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("backend", () => this.scope.unset("backend")) - : await this.commit("backend", () => this.scope.set("backend", write.backend)) - 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" } } await this.readCredential() } finally { @@ -614,6 +606,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 +635,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"), }), @@ -658,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 69365cea8..f90e3d419 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,24 @@ 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) { + 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, - baseURL: 'https://api.exa.ai', + baseURL: baseURL || 'https://api.exa.ai', searchType: 'auto', highlightsPerResult: 1, }).search(request, signal); @@ -200,6 +215,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..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) => { @@ -79,6 +81,7 @@ function loadPlugin() { type CardActions = { discard: () => void editKey: (text: string) => void + editBaseURL: (text: string) => void hooks: { webSearchCard: { getSnapshot: () => Record } } resetBackend: () => Promise | void save: () => Promise @@ -90,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 @@ -102,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" }, @@ -130,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({ @@ -165,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. */ @@ -190,6 +210,146 @@ 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/ ") + 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 @@ -281,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 @@ -318,7 +478,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 +487,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() } @@ -603,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 e5f4294bc..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,72 @@ 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" }, + { 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. 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: