From bda1cd7c8524f87a37837ccd942ed1637a0f8059 Mon Sep 17 00:00:00 2001 From: Louise Lau Date: Thu, 2 Jul 2026 09:41:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ux):=20API-key=20guidance=20+=20save=5Fapi?= =?UTF-8?q?=5Fkey=20=E2=80=94=20no=20more=20silent=20"can't=20search"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure this removes: a user sets up models, asks for a web task, and Firecrawl/Tavily/Brave are skipped for lack of a key — the task limps on the keyless fallback or fails, and the user never learns why or what to do. - key-guidance.ts (PURE): registry of web service keys (Firecrawl/Tavily/Brave — URL, free-tier, what each unlocks) + keyGuidance(env) + missingWebKeysGuidance(env) — fires only when NO content-grade key is set (one key set → no nagging). - save_api_key tool: when the user pastes a key in chat ("set it for me"), stores it via the existing setEnvKey (~/.qodex/.env, chmod 600, atomic) AND loads it into process.env so the blocked task retries in the SAME session. Validates the var name/value; the value is never echoed or logged. - web_search failure paths + the three keyed backends now return that guidance: get a free key at → paste it in chat (agent saves + retries) or add to ~/.qodex/.env. - Dashboard Health gains a "Web search" badge: green when any content-grade key is set; else "keyless fallback only — get a free Firecrawl key at …" (input optional → old callers/tests untouched). --- src/cli/dashboard-observability.ts | 13 ++++++ src/cli/dashboard.ts | 10 ++++- src/setup/key-guidance.ts | 70 ++++++++++++++++++++++++++++++ src/tools/builtin/save-api-key.ts | 49 +++++++++++++++++++++ src/tools/registry.ts | 2 + src/tools/web/brave.ts | 3 +- src/tools/web/firecrawl.ts | 5 ++- src/tools/web/tavily.ts | 5 ++- src/tools/web/web-search.ts | 12 +++-- test/key-guidance.test.ts | 66 ++++++++++++++++++++++++++++ 10 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 src/setup/key-guidance.ts create mode 100644 src/tools/builtin/save-api-key.ts create mode 100644 test/key-guidance.test.ts diff --git a/src/cli/dashboard-observability.ts b/src/cli/dashboard-observability.ts index 587ca1b..b1c99c5 100644 --- a/src/cli/dashboard-observability.ts +++ b/src/cli/dashboard-observability.ts @@ -18,6 +18,9 @@ export function computeHealth(input: { botRunning: boolean; modelSet: boolean; lastRunStatus?: string; + /** Content-grade web-search keys (Firecrawl/Tavily/Brave): how many are set, and the first + * missing one to suggest. Omitted → no badge (old callers unaffected). */ + webKeys?: { set: number; total: number; suggest?: { service: string; env: string; url: string } }; }): HealthItem[] { const cloud = input.providers.filter(p => p.keyEnv); const ready = cloud.filter(p => p.keySet).length; @@ -28,6 +31,16 @@ export function computeHealth(input: { detail: cloud.length === 0 ? 'all local' : `${ready}/${cloud.length} cloud keys set`, }); items.push({ label: 'Default model', ok: input.modelSet, detail: input.modelSet ? 'configured' : 'unset — run `qodex setup`' }); + if (input.webKeys) { + const w = input.webKeys; + items.push({ + label: 'Web search', + ok: w.set > 0, + detail: w.set > 0 + ? `${w.set}/${w.total} content-grade backend(s) keyed` + : `keyless fallback only — ${w.suggest ? `get a free ${w.suggest.service} key at ${w.suggest.url} (${w.suggest.env})` : 'set a search API key'}`, + }); + } items.push({ label: 'Scheduler', ok: true, detail: `${input.schedulesEnabled} task(s) enabled` }); items.push({ label: 'Bot', ok: true, detail: input.botRunning ? 'running' : 'stopped' }); if (input.lastRunStatus) { diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts index 903b1ce..c7b3695 100644 --- a/src/cli/dashboard.ts +++ b/src/cli/dashboard.ts @@ -416,9 +416,17 @@ export async function gatherDashboardData(cwd: string): Promise { catch { return { running: false }; } })(); const { computeHealth, tailLines } = await import('./dashboard-observability.js'); + const webKeys = await (async () => { + try { + const { webKeyStatus, WEB_SERVICE_KEYS } = await import('../setup/key-guidance.js'); + const s = webKeyStatus(process.env as Record); + const first = s.missing[0]; + return { set: s.set.length, total: WEB_SERVICE_KEYS.length, suggest: first ? { service: first.service, env: first.env, url: first.url } : undefined }; + } catch { return undefined; } + })(); const health = computeHealth({ providers, schedulesEnabled: schedules.filter(s => s.enabled).length, botRunning: bot.running, - modelSet: !!defModel && defModel !== '(unset)', lastRunStatus: runs[0]?.status, + modelSet: !!defModel && defModel !== '(unset)', lastRunStatus: runs[0]?.status, webKeys, }); const logs = await (async () => { try { const { QODEX_LOG_FILE } = await import('../config/defaults.js'); return tailLines(await fs.readFile(QODEX_LOG_FILE, 'utf-8'), 40); } diff --git a/src/setup/key-guidance.ts b/src/setup/key-guidance.ts new file mode 100644 index 0000000..2d3e298 --- /dev/null +++ b/src/setup/key-guidance.ts @@ -0,0 +1,70 @@ +/** + * API-key guidance — turn "it silently can't search" into an actionable next step. + * + * The failure mode this removes: a user sets up their models, asks for a task that needs web + * search / page content, and the keyed backends (Firecrawl / Tavily / Brave) are skipped because + * no API key is set — the task limps along on the keyless fallback or fails, and the user never + * learns WHY or what to do. Instead, every key-related failure now tells them exactly: + * 1. where to get the key (with the free-tier note), + * 2. how to set it themselves (~/.qodex/.env or a shell export), and + * 3. that they can simply PASTE it in chat — the agent saves it with the `save_api_key` tool + * and continues the task in the same session. + * + * PURE (env passed in) — unit-tested. The registry is the single source of truth reused by the + * backend error messages, the web_search failure summary, and the dashboard health badge. + */ + +export interface ServiceKey { + env: string; + service: string; + url: string; // where to get the key + unlocks: string; // what capability it enables, in user terms + freeTier: boolean; +} + +/** Keys that unlock web capabilities. Order = recommendation order. */ +export const WEB_SERVICE_KEYS: readonly ServiceKey[] = [ + { env: 'FIRECRAWL_API_KEY', service: 'Firecrawl', url: 'https://www.firecrawl.dev', unlocks: 'search with full-page content + the best page extraction', freeTier: true }, + { env: 'TAVILY_API_KEY', service: 'Tavily', url: 'https://app.tavily.com', unlocks: 'fast LLM-grade web search', freeTier: true }, + { env: 'BRAVE_SEARCH_API_KEY', service: 'Brave Search', url: 'https://api-dashboard.search.brave.com', unlocks: 'independent-index web search', freeTier: true }, +] as const; + +export function findServiceKey(env: string): ServiceKey | undefined { + return WEB_SERVICE_KEYS.find(k => k.env === env); +} + +/** One-key guidance block: where to get it + the three ways to set it. PURE. */ +export function keyGuidance(env: string): string { + const k = findServiceKey(env); + const from = k ? `Get a key at ${k.url}${k.freeTier ? ' (free tier available)' : ''} — it unlocks ${k.unlocks}.` : `This needs the ${env} environment variable.`; + return [ + from, + 'Then any of:', + ` • paste the key here in chat — I'll store it safely (~/.qodex/.env, chmod 600) and continue your task`, + ` • add \`${env}=\` to ~/.qodex/.env yourself`, + ` • or \`export ${env}=\` in your shell`, + ].join('\n'); +} + +/** Which web keys are set / missing, given an env-like map. PURE. */ +export function webKeyStatus(env: Record): { set: ServiceKey[]; missing: ServiceKey[] } { + const set: ServiceKey[] = [], missing: ServiceKey[] = []; + for (const k of WEB_SERVICE_KEYS) (env[k.env] ? set : missing).push(k); + return { set, missing }; +} + +/** + * The "your search is running degraded" message: shown when a web task failed (or fell back to + * the keyless engine) while content-grade backends sit unused for lack of a key. PURE. + */ +export function missingWebKeysGuidance(env: Record): string | null { + const { set, missing } = webKeyStatus(env); + if (set.length > 0 || missing.length === 0) return null; // at least one content-grade backend works + const lines = [ + 'Web search/extract is limited right now: no search API key is set, so only the keyless fallback is available.', + 'To unlock a content-grade backend (pick ONE — all have free tiers):', + ...missing.map(k => ` • ${k.service}: ${k.url} → ${k.env} (${k.unlocks})`), + 'You can paste a key here in chat and I will store it safely (~/.qodex/.env) and retry — or add it to ~/.qodex/.env yourself.', + ]; + return lines.join('\n'); +} diff --git a/src/tools/builtin/save-api-key.ts b/src/tools/builtin/save-api-key.ts new file mode 100644 index 0000000..166094f --- /dev/null +++ b/src/tools/builtin/save-api-key.ts @@ -0,0 +1,49 @@ +/** + * `save_api_key` — when the user pastes an API key in chat ("here, set it for me"), store it in + * ~/.qodex/.env (chmod 600, atomic, the same place `qodex provider add` uses) AND load it into the + * running process, so the interrupted task can resume in the SAME session without a restart. + * + * Security posture: the value is never echoed back, never logged, and never written to config.yaml + * (secrets live in env, not config). Only the env-var NAME appears in the confirmation. + */ +import { z } from 'zod'; +import { Tool, ToolContext, ToolResult } from '../base.js'; + +const Args = z.object({ + env_var: z.string().describe('The environment variable name, e.g. FIRECRAWL_API_KEY or TAVILY_API_KEY.'), + value: z.string().describe('The key value the user provided. Stored in ~/.qodex/.env (0600); never echoed back.'), +}); + +export class SaveApiKeyTool extends Tool> { + name = 'save_api_key'; + description = 'Store an API key the USER just provided in chat (e.g. FIRECRAWL_API_KEY / TAVILY_API_KEY / BRAVE_SEARCH_API_KEY) into ~/.qodex/.env (chmod 600) and load it into this session, so a blocked task (web search, page extraction, a cloud provider) can continue immediately. Use ONLY with a key the user explicitly pasted — never invent or reuse values. The value is never echoed back or logged.'; + isReadOnly = false; + isDestructive = false; + argsSchema = Args; + + async execute(args: z.infer, _ctx: ToolContext): Promise { + const envVar = args.env_var.trim().toUpperCase(); + const value = args.value.trim(); + if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(envVar)) { + return { content: `[SAVE_KEY_ERROR] "${envVar}" is not a valid environment variable name (A-Z, 0-9, _).`, isError: true }; + } + if (!value || value.length < 8) { + return { content: '[SAVE_KEY_ERROR] That does not look like a key (too short). Ask the user to re-paste it.', isError: true }; + } + if (/\s/.test(value)) { + return { content: '[SAVE_KEY_ERROR] The value contains whitespace — probably a paste accident. Ask the user to re-paste just the key.', isError: true }; + } + + const { setEnvKey } = await import('../../setup/env-writer.js'); + const file = await setEnvKey(envVar, value); + process.env[envVar] = value; // live for THIS session — the blocked task can retry right away + + const { findServiceKey } = await import('../../setup/key-guidance.js'); + const svc = findServiceKey(envVar); + const unlocked = svc ? ` ${svc.service} is now available — ${svc.unlocks}.` : ''; + return { + content: `✓ ${envVar} saved to ${file} (chmod 600) and loaded into this session.${unlocked} Retry the blocked step now.`, + metadata: { envVar, service: svc?.service }, + }; + } +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 1ecb064..878256b 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -95,6 +95,7 @@ import { DiagnosticsTool } from './diagnostics/diagnostics-tool.js'; import { RememberTool, RecallTool, ForgetTool } from './builtin/memory.js'; import { RecallApproachTool } from './builtin/recall-approach.js'; import { SuggestSkillTool } from './builtin/suggest-skill.js'; +import { SaveApiKeyTool } from './builtin/save-api-key.js'; import { AddProviderTool } from './builtin/add-provider.js'; // v1.40 — infrastructure tool groups import { NetworkOptimizeTool } from './network/network-optimize.js'; @@ -289,6 +290,7 @@ export class ToolRegistry { new RecallTool(), new RecallApproachTool(), new SuggestSkillTool(), + new SaveApiKeyTool(), new ForgetTool(), new AddProviderTool(), new ProjectLogTool(), diff --git a/src/tools/web/brave.ts b/src/tools/web/brave.ts index d9b4d79..859c76b 100644 --- a/src/tools/web/brave.ts +++ b/src/tools/web/brave.ts @@ -33,7 +33,8 @@ export class BraveBackend implements WebSearchBackend { if (!apiKey) { throw new WebSearchError( 'Brave backend selected but BRAVE_SEARCH_API_KEY is not set. ' + - 'Get a key at https://api.search.brave.com (free tier 2000 q/month).', + 'Get a free key at https://api-dashboard.search.brave.com — then paste it in chat (the agent saves it via save_api_key and retries), ' + + 'or add BRAVE_SEARCH_API_KEY=... to ~/.qodex/.env.', this.name, ); } diff --git a/src/tools/web/firecrawl.ts b/src/tools/web/firecrawl.ts index 9c3f662..a0eefcf 100644 --- a/src/tools/web/firecrawl.ts +++ b/src/tools/web/firecrawl.ts @@ -31,8 +31,9 @@ export class FirecrawlBackend implements WebSearchBackend { const apiKey = process.env.FIRECRAWL_API_KEY; if (!apiKey) { throw new WebSearchError( - 'Firecrawl backend selected but FIRECRAWL_API_KEY is not set in the environment. ' + - 'Export FIRECRAWL_API_KEY=fc-... or switch to another backend in your config.', + 'Firecrawl backend selected but FIRECRAWL_API_KEY is not set. ' + + 'Get a free key at https://www.firecrawl.dev — then paste it in chat (the agent saves it via save_api_key and retries), ' + + 'or add FIRECRAWL_API_KEY=fc-... to ~/.qodex/.env.', this.name, ); } diff --git a/src/tools/web/tavily.ts b/src/tools/web/tavily.ts index 5bb85b1..12236cc 100644 --- a/src/tools/web/tavily.ts +++ b/src/tools/web/tavily.ts @@ -22,8 +22,9 @@ export class TavilyBackend implements WebSearchBackend { const apiKey = process.env.TAVILY_API_KEY; if (!apiKey) { throw new WebSearchError( - 'Tavily backend selected but TAVILY_API_KEY is not set in the environment. ' + - 'Either export TAVILY_API_KEY=... or switch to the duckduckgo backend in your config.', + 'Tavily backend selected but TAVILY_API_KEY is not set. ' + + 'Get a free key at https://app.tavily.com — then paste it in chat (the agent saves it via save_api_key and retries), ' + + 'or add TAVILY_API_KEY=... to ~/.qodex/.env.', this.name, ); } diff --git a/src/tools/web/web-search.ts b/src/tools/web/web-search.ts index f3c7454..cda35e7 100644 --- a/src/tools/web/web-search.ts +++ b/src/tools/web/web-search.ts @@ -149,14 +149,19 @@ export class WebSearchTool extends Tool> { } } + // When failing, tell the user EXACTLY how to unlock a content-grade backend — including that + // they can paste a key in chat and the agent saves it (save_api_key) and retries. + const { missingWebKeysGuidance } = await import('../../setup/key-guidance.js'); + const guidance = missingWebKeysGuidance(process.env as Record); + const unlockHint = guidance ? `\n\n${guidance}` : '\n\nRun /network to check connectivity.'; + // Every backend errored at the transport level (and none cleanly returned an // empty result set) → this is a real failure, not just "nothing found". if (anyTransportError && !anyEmpty) { return { isError: true, content: `[WEB_SEARCH_ERROR] All ${chain.length} backend(s) errored for "${args.query}":\n` + - failures.map(f => ` - ${f}`).join('\n') + - `\n\nRun /network to check connectivity, or set TAVILY_API_KEY / BRAVE_SEARCH_API_KEY for additional backends.`, + failures.map(f => ` - ${f}`).join('\n') + unlockHint, }; } @@ -164,8 +169,7 @@ export class WebSearchTool extends Tool> { return { content: `[NO_RESULTS] All ${chain.length} backend(s) failed for "${args.query}":\n` + failures.map(f => ` - ${f}`).join('\n') + - `\n\nTry a different/more specific query, run /network to check connectivity, ` + - `or set TAVILY_API_KEY / BRAVE_SEARCH_API_KEY for additional backends.`, + `\n\nTry a different/more specific query.` + unlockHint, }; } } diff --git a/test/key-guidance.test.ts b/test/key-guidance.test.ts new file mode 100644 index 0000000..67f775a --- /dev/null +++ b/test/key-guidance.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { keyGuidance, webKeyStatus, missingWebKeysGuidance, findServiceKey, WEB_SERVICE_KEYS } from '../src/setup/key-guidance.ts'; +import { computeHealth } from '../src/cli/dashboard-observability.ts'; +import { SaveApiKeyTool } from '../src/tools/builtin/save-api-key.ts'; + +describe('key guidance — actionable, never a dead end', () => { + it('per-key guidance names the signup URL, the env var, and the paste-in-chat option', () => { + const g = keyGuidance('FIRECRAWL_API_KEY'); + expect(g).toContain('firecrawl.dev'); + expect(g).toContain('free tier'); + expect(g).toContain('FIRECRAWL_API_KEY='); + expect(g).toMatch(/paste the key here in chat/); + expect(g).toContain('~/.qodex/.env'); + }); + + it('webKeyStatus splits set vs missing', () => { + const s = webKeyStatus({ TAVILY_API_KEY: 'tvly-x' }); + expect(s.set.map(k => k.env)).toEqual(['TAVILY_API_KEY']); + expect(s.missing).toHaveLength(WEB_SERVICE_KEYS.length - 1); + }); + + it('missingWebKeysGuidance fires ONLY when no content-grade key is set', () => { + const none = missingWebKeysGuidance({}); + expect(none).toMatch(/no search API key is set/); + expect(none).toContain('https://www.firecrawl.dev'); + expect(none).toContain('https://app.tavily.com'); + expect(none).toMatch(/paste a key here in chat/); + expect(missingWebKeysGuidance({ FIRECRAWL_API_KEY: 'fc-x' })).toBeNull(); // one key → no nag + }); + + it('findServiceKey resolves known env names', () => { + expect(findServiceKey('TAVILY_API_KEY')!.service).toBe('Tavily'); + expect(findServiceKey('NOT_A_KEY')).toBeUndefined(); + }); +}); + +describe('health badge — web search readiness', () => { + const base = { providers: [], schedulesEnabled: 0, botRunning: false, modelSet: true }; + it('warns with a concrete suggestion when zero keys are set', () => { + const items = computeHealth({ ...base, webKeys: { set: 0, total: 3, suggest: { service: 'Firecrawl', env: 'FIRECRAWL_API_KEY', url: 'https://www.firecrawl.dev' } } }); + const w = items.find(i => i.label === 'Web search')!; + expect(w.ok).toBe(false); + expect(w.detail).toContain('keyless fallback only'); + expect(w.detail).toContain('firecrawl.dev'); + }); + it('is green once any key is set; absent input → no badge (back-compat)', () => { + const w = computeHealth({ ...base, webKeys: { set: 1, total: 3 } }).find(i => i.label === 'Web search')!; + expect(w.ok).toBe(true); + expect(computeHealth(base).find(i => i.label === 'Web search')).toBeUndefined(); + }); +}); + +describe('save_api_key — input validation (no I/O paths)', () => { + const tool = new SaveApiKeyTool(); + it('rejects a bad env-var name, a too-short value, and whitespace pastes', async () => { + expect((await tool.execute({ env_var: 'bad name!', value: 'x'.repeat(20) }, {} as any)).isError).toBe(true); + expect((await tool.execute({ env_var: 'TAVILY_API_KEY', value: 'short' }, {} as any)).isError).toBe(true); + const ws = await tool.execute({ env_var: 'TAVILY_API_KEY', value: 'abc def ghij klmno' }, {} as any); + expect(ws.isError).toBe(true); + expect(ws.content).toMatch(/whitespace/); + }); + it('never echoes the value back in any error message', async () => { + const r = await tool.execute({ env_var: 'bad name!', value: 'SUPER-SECRET-VALUE-123' }, {} as any); + expect(r.content).not.toContain('SUPER-SECRET-VALUE-123'); + }); +});