diff options
| author | Igor Soarez <igor@soarez.org> | 2026-08-03 21:43:56 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-08-03 21:43:56 +0100 |
| commit | db69207c06e8d5233bff4996e3ef5b43332d533f (patch) | |
| tree | 032ebbc3983a6eb4f9e3c4ac162c60a9cc1edc75 /src/engine-store.ts | |
| parent | 495de0d5283dd3e4a6ef715b596c4a2892e95915 (diff) | |
Support DuckDuckGo, Bing and Brave, switchable like the CDP host
Engine resolution mirrors the browser target: CCS_SEARCH_ENGINE, then a
/search-engine choice persisted machine-wide, then Google. A per-call
`engine` parameter sits above both so the agent can fall back when one
engine starts serving captchas — the one case where the model, not the
operator, has to make the call. Unlike the CDP target there is no safety
argument for the environment winning: driving the wrong browser means
automating someone's signed-in Chrome, choosing a different index does not.
An unsupported recency window is refused, naming the engines that support
it, rather than dropped. Silently returning unfiltered results is
indistinguishable from success, which is the failure this whole design is
trying to avoid.
Extraction grows a second mode. DuckDuckGo, Bing and Brave have clean
per-result containers; Google does not, so its heading-walk stays as its
own path rather than being bent into the item shape. DuckDuckGo and Bing
route links through redirectors, unwrapped in the page.
Third silent-failure trap found, alongside Google's two: on Bing, `count`
cancels `filters`. With ex1:"ez1" alone every result is hours old; add
count in either order and months-old results return, looking perfectly
ordinary. Bing now drops count whenever a date filter is present.
Challenge detection widened to Brave's "Verifying you're not a bot" and
"Quick check before you continue searching", which the previous Google-
shaped matcher missed entirely — found by tripping it.
Diffstat (limited to 'src/engine-store.ts')
| -rw-r--r-- | src/engine-store.ts | 81 |
1 files changed, 81 insertions, 0 deletions
diff --git a/src/engine-store.ts b/src/engine-store.ts new file mode 100644 index 0000000..d218d2b --- /dev/null +++ b/src/engine-store.ts @@ -0,0 +1,81 @@ +/** + * Persistence for the `/search-engine` choice. + * + * Deliberately the same shape, and the same atomic write, as the harness's + * browser-target store: a sibling temp file plus rename, so a crash mid-write + * cannot leave a half-written engine name behind. Its own file rather than a + * shared settings blob, so a corrupt engine choice cannot cost anything else. + * + * Like the CDP target, the stored value is the raw string a user would have put + * in the environment variable ("ddg", "bing", …), not a pre-parsed id. One + * vocabulary, one resolution path in engine-target.ts, and a stored value can + * never mean something the variable could not. + * + * Every read failure degrades to "nothing stored" rather than throwing. + */ + +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname, join } from "node:path"; + +import { agentDir } from "./paths.ts"; + +const CURRENT_VERSION = 1; + +const engineFilePath = (): string => join(agentDir(), "castle-cdp-search-engine.json"); + +type EngineFile = { + readonly version: 1; + /** Raw CCS_SEARCH_ENGINE-style value, or null when cleared. */ + readonly engine: string | null; + readonly savedAt: string; +}; + +/** + * The persisted engine, or null when nothing is stored, the file is missing, or + * it cannot be parsed. A file written by a newer version is treated as "nothing + * stored" rather than guessed at. + */ +export const readStoredEngine = async (): Promise<string | null> => { + let raw: string; + try { + raw = await readFile(engineFilePath(), "utf8"); + } catch { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const file = parsed as Record<string, unknown>; + if (file["version"] !== CURRENT_VERSION) return null; + const engine = file["engine"]; + if (typeof engine !== "string") return null; + const trimmed = engine.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +}; + +const write = async (engine: string | null): Promise<void> => { + const path = engineFilePath(); + const tmp = `${path}.${randomUUID()}.tmp`; + const payload: EngineFile = { version: CURRENT_VERSION, engine, savedAt: new Date().toISOString() }; + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmp, path); + } catch (e) { + await unlink(tmp).catch(() => {}); + throw new Error(`could not save search engine to ${path}: ${e instanceof Error ? e.message : String(e)}`); + } +}; + +/** Persist the chosen engine. */ +export const writeStoredEngine = (engine: string): Promise<void> => write(engine); + +/** Clear the choice, restoring the built-in default. */ +export const clearStoredEngine = (): Promise<void> => write(null); + +/** Exposed so the command can tell the user where the choice lives. */ +export const storedEngineLocation = (): string => engineFilePath(); |
