/** * Web-search configuration: types, defaults, layered loading, and the * security-critical project-config allowlist. * * Config is layered: * 1. DEFAULTS (built in) * 2. /config.json (global) — FULL authority: * may set the Kagi token/endpoint and use secret resolution (!cmd / $ENV). * You control your own global file. * 3. /.pi/web-research.json (project) — UNTRUSTED: restricted to a * hardcoded allowlist of non-sensitive tuning knobs. It can NEVER set * kagiToken/kagiEndpoint/wynaEndpoint, and its values are NEVER run through * secret resolution. This means opening a hostile repo cannot exfiltrate the * token, redirect it to an attacker endpoint, or execute a `!command`. * * Secrets resolve from env vars first, then the global config value. The token * may also be supplied via a `.env` file in this extension directory. * * The Wyna API key is special: it's shared with the Pi LLM provider config * (~/.pi/agent/models.json → providers.wyna.apiKey) so the user doesn't need * a separate credential. It can also be set via WYNA_API_KEY env or the * wynaApiKey global config field. * * Pure except for the filesystem reads in `loadConfig`/`loadDotEnv`; no * `pi`/`ctx` dependency so the merge + allowlist semantics can be unit-tested. */ import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; /** * Root of this extension (the directory containing package.json), derived from * this module's own location so the checkout can live under any directory name. * The `.env` file and the global config.json are read from here. */ export const EXTENSION_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export interface WebSearchConfig { kagiToken?: string; // v1 Bearer API key (from kagi.com/api/keys). Global-only. kagiEndpoint?: string; // v1 endpoint (POST). Global-only. kagiV0Token?: string; // legacy v0 "Bot" token. Global-only. kagiV0Endpoint?: string; // legacy v0 endpoint (GET). Global-only. wynaApiKey?: string; // Wyna API key override (normally read from models.json / WYNA_API_KEY). Global-only. wynaEndpoint?: string; // Wyna search endpoint. Global-only. searchProvider?: string; // "kagi" (=v1, default) | "kagi-v0" (legacy) | "wyna". Selects the backend. Global-only. maxResults?: number; perPageMaxChars?: number; totalMaxChars?: number; fetchTimeoutMs?: number; concurrency?: number; userAgent?: string; allowPrivateNetwork?: boolean; // opt out of the SSRF private-IP guard (localhost docs) llmsTxtEnabled?: boolean; llmsTxtMaxChars?: number; llmsTxtFetchFull?: boolean; subAgentModel?: string; // "provider/id"; default: main agent's current model (web_search briefing) subAgentThinking?: string; // off|minimal|low|medium|high|xhigh fetchModel?: string; // "provider/id" for web_fetch per-page RAG; falls back to subAgentModel/main researchMaxHops?: number; // extra cited pages the web_search sub-agent may fetch (0 disables) // web_search summary mode (A/B): "comprehensive" reads top N pages and returns a // full briefing; "concise" returns a short snippet-based overview and defers full // reads to on-demand web_fetch(url, prompt) follow-ups. summaryMode?: "comprehensive" | "concise"; // Default depth for web_fetch when no per-call `mode` and no `prompt` is given: // "concise"/"thorough" run a page-summarizer sub-agent; "full" returns raw markdown. // A per-call `mode` always overrides this. fetchMode?: "concise" | "thorough" | "full"; // --- bot-protected-page fallbacks (Reddit / Cloudflare etc.) --- // Escalate a bot-blocked fetch to a sandboxed headless Chromium (Playwright). // Inert until the optional `playwright` package + chromium binary are installed. browserFallbackEnabled?: boolean; browserTimeoutMs?: number; // navigation/settle budget for the headless render // Remove Chromium's renderer sandbox (RCE-adjacent: it contains hostile page JS). // Global-only; only flip on hosts where user namespaces cannot be enabled. browserNoSandbox?: boolean; // Optional self-hosted reader endpoint (e.g. Jina Reader / FlareSolverr). Egress- // sensitive (target URLs leave the machine) → global-config-only, no default. readerEndpoint?: string; readerMode?: "jina" | "flaresolverr" | "auto"; acceptLanguage?: string; // Accept-Language header for the plain-fetch path } export const DEFAULTS: Required> = { kagiEndpoint: "https://kagi.com/api/v1/search", kagiV0Endpoint: "https://kagi.com/api/v0/search", wynaEndpoint: "https://ai.wyna.info/api/search", searchProvider: "kagi", // = v1 maxResults: 5, perPageMaxChars: 12000, totalMaxChars: 40000, fetchTimeoutMs: 12000, concurrency: 5, userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", allowPrivateNetwork: false, llmsTxtEnabled: true, llmsTxtMaxChars: 8000, llmsTxtFetchFull: false, subAgentModel: "", subAgentThinking: "off", fetchModel: "", researchMaxHops: 3, summaryMode: "concise", fetchMode: "concise", browserFallbackEnabled: true, browserTimeoutMs: 20000, browserNoSandbox: false, readerEndpoint: "", readerMode: "auto", acceptLanguage: "en-US,en;q=0.9", }; /** * Keys a project-level `.pi/web-research.json` is allowed to set. Deliberately * excludes the Kagi credentials/endpoints (kagiToken, kagiEndpoint, kagiV0Token, * kagiV0Endpoint), searchProvider, and allowPrivateNetwork — these are * security-sensitive (creds / where queries and fetches go) and only the user's * own global config may set them. Likewise the bot-fallback keys * browserFallbackEnabled / browserNoSandbox (spawn Chromium / drop its sandbox) * and readerEndpoint / readerMode (egress destination) are global-only — a * hostile repo must not enable browser execution or redirect fetches off-box. */ export const PROJECT_ALLOWED_KEYS = [ "maxResults", "perPageMaxChars", "totalMaxChars", "fetchTimeoutMs", "concurrency", "userAgent", "llmsTxtEnabled", "llmsTxtMaxChars", "llmsTxtFetchFull", "subAgentModel", "subAgentThinking", "fetchModel", "researchMaxHops", "summaryMode", "fetchMode", "browserTimeoutMs", "acceptLanguage", ] as const satisfies readonly (keyof WebSearchConfig)[]; /** * Strip any key not on the project allowlist, reporting each dropped key. This * is the core of the RCE/exfil fix: a hostile repo's kagiToken/kagiEndpoint/ * allowPrivateNetwork never survive into the merged config. */ export function sanitizeProjectConfig( raw: Partial, onError: (message: string) => void = () => {}, ): WebSearchConfig { const allowed = new Set(PROJECT_ALLOWED_KEYS); const clean: WebSearchConfig = {}; for (const [k, v] of Object.entries(raw)) { if (allowed.has(k)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (clean as any)[k] = v; } else { onError(`web-research: ignoring disallowed project-config key "${k}" (set it in global config.json instead)`); } } return clean; } /** * Resolve a secret value: !command (stdout), $ENV / ${ENV} interpolation, or * literal. ONLY ever called on values that originate from env or global config * — never on project-config values. */ export function resolveSecret(value?: string): string | undefined { if (!value) return undefined; const v = value.trim(); if (!v) return undefined; if (v.startsWith("!")) { try { return execSync(v.slice(1), { encoding: "utf-8" }).trim(); } catch { return undefined; } } if (v.startsWith("$")) { let name: string; if (v.startsWith("${")) { const close = v.indexOf("}"); if (close < 0) return undefined; // malformed ${...} name = v.slice(2, close); } else { name = v.slice(1); } return process.env[name]?.trim() || undefined; } return v; } /** * Load a `.env` file from the extension directory into process.env for any keys * not already set in the real environment (real env wins). Minimal dotenv-style * parser; supports `KEY=value`, `export KEY=value`, comments, and quotes. */ export function loadDotEnv(extensionDir: string): void { const envPath = path.join(extensionDir, ".env"); if (!existsSync(envPath)) return; let text: string; try { text = readFileSync(envPath, "utf-8"); } catch { return; } for (const rawLine of text.split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); if (!m) continue; const key = m[1]; let val = m[2].trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (process.env[key] === undefined) process.env[key] = val; } } /** v1 (default) Bearer API key: env KAGI_API_KEY, else global config kagiToken. */ export function resolveToken(cfg: WebSearchConfig): string | undefined { return ( process.env.KAGI_API_KEY?.trim() || resolveSecret(cfg.kagiToken) // cfg.kagiToken is only ever set by global config ); } /** * Legacy v0 "Bot" token: env KAGI_API_KEY_V0 (preferred), then KAGI_TOKEN * (legacy name, still honored), then global config kagiV0Token. */ export function resolveTokenV0(cfg: WebSearchConfig): string | undefined { return ( process.env.KAGI_API_KEY_V0?.trim() || process.env.KAGI_TOKEN?.trim() || resolveSecret(cfg.kagiV0Token) // cfg.kagiV0Token is only ever set by global config ); } /** * Wyna API key: resolved in this order: * 1. $WYNA_API_KEY env var * 2. ~/.pi/agent/models.json → providers.wyna.apiKey (the Pi LLM provider config) * 3. global config wynaApiKey (secret resolution, e.g. "$SOME_ENV" / "!command") * * The `agentDir` parameter is only needed for option 2 (reading models.json). * When called without it (e.g. from tests / config-only contexts) it falls back * to the env var and global config field. */ export function resolveWynaApiKey(cfg: WebSearchConfig, agentDir?: string): string | undefined { // 1. Env var (highest priority) const envKey = process.env.WYNA_API_KEY?.trim(); if (envKey) return envKey; // 2. Pi provider config (~/.pi/agent/models.json → providers.wyna.apiKey) const modelsKey = wynaKeyFromModelsJson(agentDir); if (modelsKey) return modelsKey; // 3. Global config wynaApiKey with secret resolution return resolveSecret(cfg.wynaApiKey); } /** The Wyna key from the Pi provider config (~/.pi/agent/models.json), if any. */ export function wynaKeyFromModelsJson(agentDir?: string): string | undefined { if (!agentDir) return undefined; const modelsPath = path.join(agentDir, "models.json"); if (!existsSync(modelsPath)) return undefined; try { const modelsConfig = JSON.parse(readFileSync(modelsPath, "utf-8")) as { providers?: Record; }; return modelsConfig?.providers?.wyna?.apiKey?.trim() || undefined; } catch { return undefined; // ignore parse errors } } /** * Load and merge the layered config. Global config (and `.env`) live in the * extension's own directory (EXTENSION_DIR); project config comes from the * cwd. Global config has full authority; project config is restricted to the * allowlist. `onError` surfaces parse failures / dropped keys. */ export function loadConfig(cwd: string, onError: (message: string) => void = () => {}): WebSearchConfig { loadDotEnv(EXTENSION_DIR); const globalPath = path.join(EXTENSION_DIR, "config.json"); const projectPath = path.join(cwd, ".pi", "web-research.json"); const parse = (p: string): Partial | undefined => { if (!existsSync(p)) return undefined; try { return JSON.parse(readFileSync(p, "utf-8")) as Partial; } catch (e) { onError(`web-research: could not parse ${p}: ${e}`); return undefined; } }; let cfg: WebSearchConfig = {}; const global = parse(globalPath); if (global) cfg = { ...cfg, ...global }; // global: full authority const project = parse(projectPath); if (project) cfg = { ...cfg, ...sanitizeProjectConfig(project, onError) }; // project: allowlist only return cfg; }