diff --git a/README.md b/README.md index 010696944..ebd74bb9c 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ If wigolo earns a place in your setup, three things keep it going: a ⭐ **star* - **Browser won't launch on Linux** — `wigolo warmup --browser` installs the OS libraries (or prints the exact command). - **Native build error / unusual Node** — use an LTS: **Node 20, 22, or 24**. - **Behind a proxy** — `USE_PROXY=true` + `PROXY_URL`; add `NODE_EXTRA_CA_CERTS` for TLS-inspecting proxies. +- **Your agent asks permission on every call** — allow the tools in your client, then restart it; rules are read at session start. [Details](docs/troubleshooting.md#your-agent-keeps-asking-permission). The full guide covers per-symptom fixes, a "what still works when X fails" map, platform notes (incl. linux-arm64), and offline installs: **[docs/troubleshooting.md](docs/troubleshooting.md)**. diff --git a/docs/installation.md b/docs/installation.md index 05e325420..215120beb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -79,6 +79,8 @@ wigolo carries registry manifests at the repo root — `smithery.yaml`, `glama.j npx wigolo init --agents=claude-code,cursor ``` +For Claude Code, `init` also allows wigolo's tools so they don't prompt on every call — pass `--no-permissions` to skip that and approve each tool yourself. Restart Claude Code afterwards; it reads permission rules once at session start. If it still prompts, see [troubleshooting](./troubleshooting.md#your-agent-keeps-asking-permission). + For OpenCode, wigolo writes the global `~/.config/opencode/opencode.json` entry in OpenCode's local MCP format: ```json diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d4cc47a09..cbb4dc7c4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,6 +16,7 @@ wigolo doctor --fix # repairs the known failure classes automatically | `wigolo serve` exits: port in use | The daemon deliberately does not auto-rebind. The error names a free port to retry with, e.g. `wigolo serve --port 3334`. | | `wigolo serve` refuses to start on a non-loopback host | Working as designed (fail-closed). Set `WIGOLO_API_TOKEN` / `WIGOLO_API_TOKEN_FILE`, or explicitly pass `--allow-unauthenticated`. See [self-hosting](./self-hosting.md#binding-beyond-loopback). | | Fetch result says `blocked_by_challenge` | See [below](#blocked_by_challenge). | +| Your agent asks permission before every wigolo tool call | See [below](#your-agent-keeps-asking-permission). | | Search results feel thin / an engine seems dead | Degraded engines are *reported*, not hidden — check `engine_warnings`, `engine_telemetry`, and `engine_pool` in the response, and `wigolo doctor`'s per-engine table (it names the env var when an engine just wants a key, e.g. `WIGOLO_GITHUB_TOKEN`, `BRAVE_API_KEY`). | | Results are stale | Pass `force_refresh: true` (news, prices, changelogs), or clear scoped entries: `wigolo cache clear --url-pattern="*example.com*"`. Lifetimes are tunable: `CACHE_TTL_SEARCH`, `CACHE_TTL_CONTENT`. | | Everything fails behind a corporate proxy | Set `USE_PROXY=true` and `PROXY_URL` (credentials go to the OS keychain, not disk). See [configuration](./configuration.md#fetch-and-browser-engine). | @@ -50,6 +51,55 @@ Two honest facts to calibrate expectations: - **IP reputation is scored.** From datacenter IPs (VPS, CI, cloud), some challenge-protected sites will not clear even though the identical request works from a residential connection. That's a property of where you're running, not a knob wigolo forgot. - **The opt-in lever is a proxy** whose IP reputation matches your legitimate-research use — see [self-hosting](./self-hosting.md#the-datacenter-ip-reality). Credentials are keychain-stored, and politeness (robots.txt, per-domain rate limits) still applies. +## Your agent keeps asking permission + +Every wigolo tool reports MCP capability hints (`readOnlyHint`, `destructiveHint`, +`idempotentHint`, `openWorldHint`) in its `tools/list` entry, and most clients use those to +auto-approve the read-only ones. Seven of the ten are read-only. Three are not, and clients are +told so deliberately — prompting on these is correct, not a bug: + +| Tool | Why it is not read-only | +| --- | --- | +| `fetch` | `actions` runs live `click` / `type` on the page, so it can submit forms and trigger navigation | +| `cache` | `clear` deletes cached rows | +| `watch` | `create` / `delete` mutate the persistent job store | + +`fetch` is the surprising one, and it is the tool you call most. Its default path only reads, but +a capability hint describes what a tool *can* do, not what a given call does, and the hints are +static per tool — so it has to declare the widest behaviour. If you never pass `actions` and want +`fetch` auto-approved anyway, allow it explicitly with the rule below. + +Clients that ignore the hints need an explicit allow rule. + +**Claude Code in plan mode** (observed on 2.1.220). Plan mode refuses any MCP tool that is not +annotated read-only, and it decides that *before* it looks at your allow rules — so an allow +rule cannot lift it. +Before wigolo shipped these hints, every tool was treated as non-read-only and prompted on every +call in plan mode no matter what was in `settings.json`. If you are on an older wigolo, upgrade. +The three non-read-only tools above still prompt in plan mode, correctly: they change state. + +**Claude Code, normal modes.** `wigolo init --agents=claude-code` writes the allow rule for you +(pass `--no-permissions` to skip, and `wigolo doctor` reports whether it is in place). To do it +by hand, add to `~/.claude/settings.json`: + +```json +{ + "permissions": { + "allow": ["mcp__wigolo__*"] + } +} +``` + +Then **restart Claude Code**. This is the step people miss: permission rules are read once at +session start, so a session that was already open when you edited the file keeps prompting until +you restart it, and it looks like the rule did not work. + +The `mcp__wigolo__` prefix must be literal — the server segment cannot contain a glob, so +`mcp__*` is skipped with a warning and approves nothing. + +If the server name is not `wigolo` in your config, use whatever name you registered it under — +the rule matches the configured server name, not the package name. `claude mcp list` shows it. + ## Platform notes **Node version.** wigolo runs on **Node 20, 22, or 24** (LTS). Very new or unusual Node builds may not have prebuilt native binaries yet and will try to compile from source (which needs a C/C++ toolchain) — stick to an LTS to avoid that. diff --git a/src/cli/agents/claude-code.ts b/src/cli/agents/claude-code.ts index a1c67c29e..c2a7e00dd 100644 --- a/src/cli/agents/claude-code.ts +++ b/src/cli/agents/claude-code.ts @@ -2,7 +2,14 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync, execSync } from 'node:child_process'; -import { mergeBlock, removeBlock, readAsset, mergeMcpJson } from './utils.js'; +import { + mergeBlock, + removeBlock, + readAsset, + mergeMcpJson, + mergeJsonArray, + removeJsonArrayValues, +} from './utils.js'; import { installSkills as installSkillsEngine } from './skills/index.js'; function claudeDir(): string { @@ -75,6 +82,27 @@ async function installSkills(): Promise { installSkillsEngine({ scope: 'global', agents: ['claude-code'], cwd: process.cwd() }); } +// One wildcard rule rather than ten literal tool names: Claude Code supports a +// glob after the literal `mcp____` prefix, and a wildcard keeps working +// when wigolo grows an eleventh tool. +const PERMISSION_RULE = 'mcp__wigolo__*'; + +/** + * Allow wigolo's tools without a per-call prompt. + * + * Tool annotations already cover the read-only tools in hosts that honour them, + * but Claude Code consults its own allow rules outside plan mode, so without + * this every tool prompts once on first use. + */ +async function installPermissions(): Promise { + const added = mergeJsonArray( + join(claudeDir(), 'settings.json'), + ['permissions', 'allow'], + [PERMISSION_RULE], + ); + return added.length > 0; +} + async function installCommand(): Promise { const content = readAsset('blocks/claude-code/wigolo-command.md'); const commandsDir = join(claudeDir(), 'commands'); @@ -96,6 +124,24 @@ async function uninstall(): Promise<{ removed: string[] }> { // already gone or claude not found } + // Remove only the exact rule wigolo wrote — the rest of the user's allow + // list is none of our business. Guarded so an unreadable settings.json can't + // abort the CLAUDE.md and slash-command teardown below it. + try { + const removedRules = removeJsonArrayValues( + join(claudeDir(), 'settings.json'), + ['permissions', 'allow'], + [PERMISSION_RULE], + ); + if (removedRules.length > 0) { + removed.push(`~/.claude/settings.json (${PERMISSION_RULE} allow rule)`); + } + } catch (err) { + process.stderr.write( + `Leaving the ${PERMISSION_RULE} allow rule in place: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + // Remove instructions block const claudeMd = join(claudeDir(), 'CLAUDE.md'); if (existsSync(claudeMd) && removeBlock(claudeMd)) { @@ -126,5 +172,6 @@ export const claudeCodeHandler = { installInstructions, installSkills, installCommand, + installPermissions, uninstall, }; diff --git a/src/cli/agents/registry.ts b/src/cli/agents/registry.ts index a5d60033c..72bb59f05 100644 --- a/src/cli/agents/registry.ts +++ b/src/cli/agents/registry.ts @@ -20,6 +20,12 @@ export type AgentSkillHandler = { installInstructions(): Promise; installSkills?(): Promise; installCommand?(): Promise; + /** + * Allow this agent to call wigolo's tools without a per-call prompt. + * Only hosts with a writable allow-list implement it. Resolves true when it + * changed something, false when the rule was already there. + */ + installPermissions?(): Promise; uninstall(): Promise<{ removed: string[] }>; }; diff --git a/src/cli/agents/utils.ts b/src/cli/agents/utils.ts index 1c0ccf061..b0b011ae9 100644 --- a/src/cli/agents/utils.ts +++ b/src/cli/agents/utils.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, lstatSync, renameSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, lstatSync, renameSync, statSync, chmodSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; @@ -192,6 +192,141 @@ export function mergeMcpJson( writeFileSync(configPath, JSON.stringify(root, null, 2) + '\n', 'utf-8'); } +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Reject the keys that would walk into `Object.prototype`. `keyPath` is a + * module constant at every call site today, but this is an exported generic + * helper and the signature invites a dynamic caller. + */ +function assertSafeKey(key: string): string { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + throw new Error(`refusing to write reserved key "${key}"`); + } + return key; +} + +/** + * Parse a JSON config that must be an object. Anything else — invalid JSON, an + * array, a bare primitive — throws rather than being silently replaced: the + * target here is the user's own config, and a wrong guess costs them the file. + */ +function readJsonObject(configPath: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (err) { + throw new Error( + `${configPath} is not valid JSON, refusing to overwrite it: ${String(err)}`, + ); + } + if (!isPlainObject(parsed)) { + throw new Error( + `${configPath} is not a JSON object, refusing to overwrite it`, + ); + } + return parsed; +} + +/** + * Write via a sibling temp file + rename. A plain `writeFileSync` truncates + * first, so an interrupted write would leave the user with a truncated or empty + * config — the whole file, not just our one entry. + */ +function writeJsonAtomic(configPath: string, root: unknown): void { + const tmp = `${configPath}.wigolo-tmp`; + writeFileSync(tmp, JSON.stringify(root, null, 2) + '\n', 'utf-8'); + // The rename swaps the temp file's inode in wholesale, so without this the + // destination inherits the temp file's umask-default mode. A user who + // chmod-hardened their settings would have it quietly widened. + try { + chmodSync(tmp, statSync(configPath).mode); + } catch { + // No existing file to copy the mode from — leave the umask default. + } + try { + renameSync(tmp, configPath); + } catch (err) { + try { unlinkSync(tmp); } catch { /* leave it rather than mask the real error */ } + throw err; + } +} + +/** + * Append values to a string array nested at `keyPath`, creating the path when + * absent. Idempotent — values already present are skipped, so re-running an + * install never duplicates a rule. + * + * Unlike `mergeMcpJson` this must not clobber the target: the file it is aimed + * at (`~/.claude/settings.json`) is the user's own, and the array it edits sits + * beside settings wigolo has no business rewriting. Returns the values it + * actually added. + */ +export function mergeJsonArray( + configPath: string, + keyPath: string[], + values: string[], +): string[] { + mkdirSync(dirname(configPath), { recursive: true }); + + const root = existsSync(configPath) ? readJsonObject(configPath) : {}; + + let obj = root; + for (let i = 0; i < keyPath.length - 1; i++) { + const key = assertSafeKey(keyPath[i]); + if (!isPlainObject(obj[key])) { + obj[key] = {}; + } + obj = obj[key] as Record; + } + + const leaf = assertSafeKey(keyPath[keyPath.length - 1]); + const existing = Array.isArray(obj[leaf]) ? (obj[leaf] as unknown[]) : []; + const added = values.filter((v) => !existing.includes(v)); + if (added.length === 0) return []; + + obj[leaf] = [...existing, ...added]; + writeJsonAtomic(configPath, root); + return added; +} + +/** + * Remove exactly the given values from a string array nested at `keyPath`. + * Everything else in the array — and in the file — is left alone. Returns the + * values actually removed. + */ +export function removeJsonArrayValues( + configPath: string, + keyPath: string[], + values: string[], +): string[] { + if (!existsSync(configPath)) return []; + + // Unlike the install path this throws rather than returning silently, so an + // uninstall that leaves the rule behind says so instead of claiming success. + const root = readJsonObject(configPath); + + let obj = root; + for (let i = 0; i < keyPath.length - 1; i++) { + const key = assertSafeKey(keyPath[i]); + if (!isPlainObject(obj[key])) return []; + obj = obj[key] as Record; + } + + const leaf = assertSafeKey(keyPath[keyPath.length - 1]); + if (!Array.isArray(obj[leaf])) return []; + + const existing = obj[leaf] as unknown[]; + const removed = values.filter((v) => existing.includes(v)); + if (removed.length === 0) return []; + + obj[leaf] = existing.filter((v) => !values.includes(v as string)); + writeJsonAtomic(configPath, root); + return removed; +} + /** Remove the wigolo entry from a JSON MCP config, preserving other servers. */ export function removeMcpJson(configPath: string, keyPath: string[]): void { if (!existsSync(configPath)) return; diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index e9bb026b5..533f39d54 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,7 +1,7 @@ import { spawnSync, spawn } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, mkdtempSync, rmdirSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolvePythonExe } from '../python-env.js'; @@ -554,6 +554,8 @@ export async function runDoctorColdChecks(dataDir: string): Promise 0 ? `${openBreakers.length} open/half-open` : 'all closed', }); + checks.push(checkClaudeCodePermissions()); + // Data-dir writability — non-fixable (a permissions problem doctor can't // repair) but a real failure that must surface at diagnosis time. const wr = checkDataDirWritable(dataDir); @@ -567,6 +569,121 @@ export async function runDoctorColdChecks(dataDir: string): Promise `mcp__wigolo__${t}`); + +function claudeSettingsPath(): string { + return join(homedir(), '.claude', 'settings.json'); +} + +interface ClaudePermissionLists { + allow: string[]; + ask: string[]; + deny: string[]; +} + +function readClaudePermissions(): ClaudePermissionLists | null { + const path = claudeSettingsPath(); + if (!existsSync(path)) return null; + try { + const root = JSON.parse(readFileSync(path, 'utf-8')) as { + permissions?: { allow?: unknown; ask?: unknown; deny?: unknown }; + }; + const list = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; + return { + allow: list(root.permissions?.allow), + ask: list(root.permissions?.ask), + deny: list(root.permissions?.deny), + }; + } catch { + return null; + } +} + +/** + * Whether Claude Code will call wigolo's tools without prompting. + * + * Never reports `failed`, and is deliberately not `fixable`. Two reasons: + * prompting on every call is a legitimate preference, not a broken install, so + * it must not colour the exit code; and repairing it would mean `--fix` writing + * into another application's config, which is further than doctor should reach. + * When the rule is absent the detail carries the exact remedy instead. + * + * Read-only, which keeps `runDoctorColdChecks` safe to reuse from `init`. + */ +function checkClaudeCodePermissions(): DoctorCheck { + const advisory = (detail: string): DoctorCheck => ({ + name: 'claude-code-permissions', + status: 'skipped', + fixable: false, + detail, + }); + + if (!existsSync(join(homedir(), '.claude'))) { + return advisory('Claude Code not detected'); + } + + const perms = readClaudePermissions(); + if (perms === null) { + return advisory(`${claudeSettingsPath()} missing or unreadable`); + } + + // Claude Code evaluates deny, then ask, then allow — so a rule in either of + // the first two wins and reporting `ok` off the allow list alone would be + // wrong. Any overlap at all is reported rather than second-guessed: a rule + // shadowing only some tools still means the user does not get what the + // allow rule promises. + const shadows = (rules: string[]): string[] => + rules.filter((r) => CLAUDE_ALLOW_RULES.includes(r) || CLAUDE_TOOL_RULES.includes(r)); + + const denied = shadows(perms.deny); + if (denied.length > 0) { + return advisory( + `a deny rule blocks wigolo tools (${denied.join(', ')}) — deny wins over allow in ` + + `Claude Code, so remove it from permissions.deny in ${claudeSettingsPath()}`, + ); + } + + const asked = shadows(perms.ask); + if (asked.length > 0) { + return advisory( + `an ask rule forces a prompt for wigolo tools (${asked.join(', ')}) — ask wins over ` + + `allow in Claude Code, so remove it from permissions.ask in ${claudeSettingsPath()}`, + ); + } + + const covered = + CLAUDE_ALLOW_RULES.some((r) => perms.allow.includes(r)) || + CLAUDE_TOOL_RULES.every((r) => perms.allow.includes(r)); + + if (covered) { + return { + name: 'claude-code-permissions', + status: 'ok', + fixable: false, + detail: 'wigolo tools allowed without a prompt (user settings)', + }; + } + + // Scoped to user settings on purpose — project settings and managed policy + // can also allow the tools, so the wording must not claim more than it read. + return advisory( + 'wigolo tools are not allow-listed in your user settings — run ' + + '`wigolo init --agents=claude-code`, or add "mcp__wigolo__*" to permissions.allow in ' + + `${claudeSettingsPath()}, then restart Claude Code (rules are read at session start)`, + ); +} + /** Whether a stale searxng lock/port file is present (process dead / unparseable). */ function detectStaleSearxngLock(dataDir: string): boolean { const lockPath = join(dataDir, 'searxng.lock'); diff --git a/src/cli/init.ts b/src/cli/init.ts index 26f9d737e..1158fea33 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -54,6 +54,8 @@ const INIT_USAGE = [ ' --interactive Plain-text prompt flow (needs a terminal)', ' --wizard Rich guided setup wizard TUI (needs a terminal)', ' --no-warmup Skip ALL component downloads (lazy-load on first use)', + ' --no-permissions Do not allow wigolo tools in the agent (you approve each call)', + ' --permissions Explicit-on alias (allowing them is the default; no-op)', ' --warmup Explicit-on alias (full setup is the default; no-op)', ' --json Emit a machine-readable JSON summary on stdout', ' --agents= Comma-separated agent ids to auto-wire (optional; omit to set up the engine only and point any MCP client at wigolo yourself)', @@ -443,6 +445,7 @@ interface InitFlagsResolved { interactive: boolean; wizard: boolean; warmup: boolean; + permissions: boolean; json: boolean; provider?: string; search?: string; @@ -650,6 +653,18 @@ async function runInitPlain(flags: InitFlagsResolved): Promise { out(` ${warn(`Command skipped: ${message}`)}`); } } + + // The allow rule only takes effect on the host's NEXT session, so say so + // here — a user who tests immediately would otherwise conclude it failed. + if (flags.permissions && handler.installPermissions) { + try { + const changed = await handler.installPermissions(); + out(` ${ok(changed ? 'Tools allowed (restart the agent to apply)' : 'Tools already allowed')}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + out(` ${warn(`Tool permissions skipped: ${message}`)}`); + } + } } // Skills — one engine call for every selected skills-capable agent at global diff --git a/src/cli/tui/flags-types.ts b/src/cli/tui/flags-types.ts index 4e437243a..0fabb5520 100644 --- a/src/cli/tui/flags-types.ts +++ b/src/cli/tui/flags-types.ts @@ -42,6 +42,13 @@ export interface InitFlags { * use). `--warmup` is accepted as an explicit-on alias for back-compat. */ warmup: boolean; + /** + * Whether init allows wigolo's tools in hosts that keep a writable allow-list + * (currently Claude Code only). Defaults to TRUE, matching how init already + * writes the instructions block unattended. `--no-permissions` sets it false + * for anyone who prefers to approve each tool by hand. + */ + permissions: boolean; /** Emit a machine-readable JSON summary on stdout instead of the human report. */ json: boolean; provider?: string; diff --git a/src/cli/tui/flags.ts b/src/cli/tui/flags.ts index 4e8c9cab4..79e4ac0ac 100644 --- a/src/cli/tui/flags.ts +++ b/src/cli/tui/flags.ts @@ -21,6 +21,8 @@ const INIT_KNOWN = new Set([ '--interactive', '--warmup', '--no-warmup', + '--permissions', + '--no-permissions', '--json', ]); @@ -143,7 +145,7 @@ function parseCommon(args: readonly string[], known: ReadonlySet): Raw { const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama'] as const; const VALID_SEARCH_BACKENDS = ['core', 'searxng', 'hybrid'] as const; -function parseInitOnlyFlags(args: readonly string[]): { provider?: string; search?: string; interactive: boolean; wizard: boolean; warmup: boolean } { +function parseInitOnlyFlags(args: readonly string[]): { provider?: string; search?: string; interactive: boolean; wizard: boolean; warmup: boolean; permissions: boolean } { let provider: string | undefined; let search: string | undefined; let interactive = false; @@ -153,6 +155,9 @@ function parseInitOnlyFlags(args: readonly string[]): { provider?: string; searc // `--warmup` is kept as an explicit-on alias for back-compat (a no-op given // the new default). let warmup = true; + // Allowing the tools is part of wiring an agent, same as the instructions + // block init already writes. `--no-permissions` opts out. + let permissions = true; let i = 0; while (i < args.length) { @@ -184,6 +189,18 @@ function parseInitOnlyFlags(args: readonly string[]): { provider?: string; searc continue; } + if (token === '--permissions') { + permissions = true; + i++; + continue; + } + + if (token === '--no-permissions') { + permissions = false; + i++; + continue; + } + if (token.startsWith('--provider=')) { const value = token.slice('--provider='.length); if (!(VALID_PROVIDERS as readonly string[]).includes(value)) { @@ -245,12 +262,12 @@ function parseInitOnlyFlags(args: readonly string[]): { provider?: string; searc i++; } - return { provider, search, interactive, wizard, warmup }; + return { provider, search, interactive, wizard, warmup, permissions }; } export function parseInitFlags(args: readonly string[]): InitFlags { const raw = parseCommon(args, INIT_KNOWN); - const { provider, search, interactive, wizard, warmup } = parseInitOnlyFlags(args); + const { provider, search, interactive, wizard, warmup, permissions } = parseInitOnlyFlags(args); return { nonInteractive: raw.nonInteractive, agents: raw.agents, @@ -260,6 +277,7 @@ export function parseInitFlags(args: readonly string[]): InitFlags { interactive, wizard, warmup, + permissions, json: raw.json, provider, search, diff --git a/src/server.ts b/src/server.ts index 92a61bc9c..1e91fdcf1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -317,57 +317,148 @@ export function createMcpServer(subsystems: Subsystems): Server { }; }); + // Capability hints per MCP `tools/list`. Hosts use these to decide whether a + // call needs a permission prompt, so each one describes the WIDEST behaviour + // its tool can reach, not the common case. + // + // Read-only tools still populate the local content cache. That store is an + // implementation detail rather than caller-visible state, which is why they + // stay `readOnlyHint: true` while `cache` — the tool that exposes the store + // directly — does not. + // + // `idempotentHint` is inert wherever `readOnlyHint` is true (spec: "meaningful + // only when readOnlyHint == false"), so those tools carry `true` for + // consistency rather than as a claim about output stability. server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'fetch', description: TOOL_DESCRIPTIONS.fetch, inputSchema: FETCH_TOOL_SCHEMA, + // Not read-only: `actions` accepts `click` and `type`, which run as live + // Playwright interactions on the target page (tool-schemas.ts), so a + // caller can submit a form or trigger navigation. The hints cover the + // widest reachable behaviour, and a click on an arbitrary page can + // destroy remote state, so `destructiveHint` is true even though the + // no-actions path — the common one — only reads. + annotations: { + title: 'Fetch a page', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, }, { name: 'search', description: TOOL_DESCRIPTIONS.search, inputSchema: SEARCH_TOOL_SCHEMA, + annotations: { + title: 'Web search', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'crawl', description: TOOL_DESCRIPTIONS.crawl, inputSchema: CRAWL_TOOL_SCHEMA, + annotations: { + title: 'Crawl a site', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'cache', description: TOOL_DESCRIPTIONS.cache, inputSchema: CACHE_TOOL_SCHEMA, + // `clear` deletes rows; `check_changes` re-fetches every matching URL + // over the network, so this is not a closed-world tool either. + annotations: { + title: 'Search or clear the local cache', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, }, { name: 'extract', description: TOOL_DESCRIPTIONS.extract, inputSchema: EXTRACT_TOOL_SCHEMA, + annotations: { + title: 'Extract structured data', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'find_similar', description: TOOL_DESCRIPTIONS.find_similar, inputSchema: FIND_SIMILAR_TOOL_SCHEMA, + annotations: { + title: 'Find similar pages', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'research', description: TOOL_DESCRIPTIONS.research, inputSchema: RESEARCH_TOOL_SCHEMA, + annotations: { + title: 'Deep research', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'agent', description: TOOL_DESCRIPTIONS.agent, inputSchema: AGENT_TOOL_SCHEMA, + annotations: { + title: 'Autonomous data gathering', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, { name: 'diff', description: TOOL_DESCRIPTIONS.diff, inputSchema: DIFF_TOOL_SCHEMA, + annotations: { + title: 'Diff two versions', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, { name: 'watch', description: TOOL_DESCRIPTIONS.watch, inputSchema: WATCH_TOOL_SCHEMA, + // `create`/`delete`/`pause`/`resume` mutate the persistent job store. + annotations: { + title: 'Watch a URL for changes', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, }, ], })); diff --git a/tests/unit/cli/agents/claude-code-permissions.test.ts b/tests/unit/cli/agents/claude-code-permissions.test.ts new file mode 100644 index 000000000..4735ed85a --- /dev/null +++ b/tests/unit/cli/agents/claude-code-permissions.test.ts @@ -0,0 +1,171 @@ +/** + * `~/.claude/settings.json` is the user's own file and holds far more than + * wigolo's allow rule, so every assertion here is about NOT damaging it: + * unrelated keys survive, an existing allow list survives, a re-run adds + * nothing, and uninstall takes back exactly one string. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, statSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { mergeJsonArray, removeJsonArrayValues } from '../../../../src/cli/agents/utils.js'; + +const RULE = 'mcp__wigolo__*'; + +let tmpHome: string; +let settingsPath: string; + +function readSettings(): Record { + return JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record; +} + +function allowList(): string[] { + const s = readSettings() as { permissions?: { allow?: string[] } }; + return s.permissions?.allow ?? []; +} + +function writeSettings(value: unknown): void { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(value, null, 2), 'utf-8'); +} + +describe('claude-code tool permissions', () => { + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'wigolo-cc-perms-')); + settingsPath = join(tmpHome, '.claude', 'settings.json'); + }); + afterEach(() => { + try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('creates the file and the nested path when nothing exists', () => { + const added = mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + expect(added).toEqual([RULE]); + expect(allowList()).toEqual([RULE]); + }); + + it('appends to an existing allow list without disturbing its entries', () => { + writeSettings({ permissions: { allow: ['Bash(ls:*)', 'WebFetch'], deny: ['Read(**/.env)'] } }); + + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(allowList()).toEqual(['Bash(ls:*)', 'WebFetch', RULE]); + const s = readSettings() as { permissions: { deny: string[] } }; + expect(s.permissions.deny).toEqual(['Read(**/.env)']); + }); + + it('preserves unrelated top-level settings', () => { + writeSettings({ model: 'opus', hooks: { Stop: [{ matcher: '*' }] }, permissions: { allow: [] } }); + + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + + const s = readSettings() as { model: string; hooks: unknown }; + expect(s.model).toBe('opus'); + expect(s.hooks).toEqual({ Stop: [{ matcher: '*' }] }); + }); + + it('is a no-op on re-run — no duplicate rule', () => { + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + const second = mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(second).toEqual([]); + expect(allowList()).toEqual([RULE]); + }); + + it('refuses to overwrite a settings file it cannot parse', () => { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync(settingsPath, '{ this is not json', 'utf-8'); + + expect(() => mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE])).toThrow( + /not valid JSON/, + ); + // The unparseable original is still on disk, untouched. + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not json'); + }); + + it('uninstall removes only wigolo\'s rule', () => { + writeSettings({ permissions: { allow: ['Bash(ls:*)', RULE, 'WebFetch'] } }); + + const removed = removeJsonArrayValues(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(removed).toEqual([RULE]); + expect(allowList()).toEqual(['Bash(ls:*)', 'WebFetch']); + }); + + it('uninstall is a no-op when the rule was never there', () => { + writeSettings({ permissions: { allow: ['WebFetch'] } }); + + const removed = removeJsonArrayValues(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(removed).toEqual([]); + expect(allowList()).toEqual(['WebFetch']); + }); + + it('uninstall on a missing file does nothing and creates nothing', () => { + const removed = removeJsonArrayValues(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(removed).toEqual([]); + expect(existsSync(settingsPath)).toBe(false); + }); + + it('refuses a settings file that is not a JSON object', () => { + // An array parses fine but JSON.stringify would drop the key we added, + // so the write must not report success. + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync(settingsPath, '["not", "an", "object"]', 'utf-8'); + + expect(() => mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE])).toThrow( + /not a JSON object/, + ); + expect(readFileSync(settingsPath, 'utf-8')).toBe('["not", "an", "object"]'); + }); + + it('refuses to walk into Object.prototype', () => { + expect(() => mergeJsonArray(settingsPath, ['__proto__', 'allow'], [RULE])).toThrow( + /reserved key/, + ); + expect(({} as Record).allow).toBeUndefined(); + }); + + it('preserves a hardened file mode across the atomic rename', () => { + // The rename swaps inodes, so without an explicit chmod the destination + // would come back at the umask default and silently widen the user's + // permissions. + writeSettings({ permissions: { allow: ['WebFetch'] } }); + chmodSync(settingsPath, 0o600); + + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(statSync(settingsPath).mode & 0o777).toBe(0o600); + expect(allowList()).toEqual(['WebFetch', RULE]); + }); + + it('preserves the file mode on removal too', () => { + writeSettings({ permissions: { allow: ['WebFetch', RULE] } }); + chmodSync(settingsPath, 0o600); + + removeJsonArrayValues(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(statSync(settingsPath).mode & 0o777).toBe(0o600); + }); + + it('leaves no temp file behind after a successful write', () => { + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + expect(existsSync(`${settingsPath}.wigolo-tmp`)).toBe(false); + }); + + it('replaces a non-array value at the leaf rather than crashing', () => { + // Defensive: a hand-edited config could have the wrong shape here. + writeSettings({ permissions: { allow: 'not-an-array' } }); + + mergeJsonArray(settingsPath, ['permissions', 'allow'], [RULE]); + + expect(allowList()).toEqual([RULE]); + }); + + it('never touches the real home directory', () => { + // Guard against a future refactor that resolves the path itself. + expect(settingsPath.startsWith(tmpHome)).toBe(true); + expect(settingsPath.startsWith(join(homedir(), '.claude'))).toBe(false); + }); +}); diff --git a/tests/unit/cli/doctor-permissions.test.ts b/tests/unit/cli/doctor-permissions.test.ts new file mode 100644 index 000000000..88276e586 --- /dev/null +++ b/tests/unit/cli/doctor-permissions.test.ts @@ -0,0 +1,151 @@ +/** + * The doctor check must be read-only: `runDoctorColdChecks` is reused by `init` + * and is documented as writing zero bytes, so a check that touched + * `~/.claude/settings.json` would break that contract. + * + * `homedir()` is stubbed rather than set through `HOME` so the redirection is + * scoped to this file and cannot depend on suite-wide env ordering. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let tmpHome: string; + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + default: { ...actual, homedir: () => tmpHome }, + homedir: () => tmpHome, + }; +}); + +function writeSettings(value: unknown): void { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync(join(tmpHome, '.claude', 'settings.json'), JSON.stringify(value, null, 2), 'utf-8'); +} + +async function permissionCheck() { + const { runDoctorColdChecks } = await import('../../../src/cli/doctor.js'); + const checks = await runDoctorColdChecks(join(tmpHome, '.wigolo')); + const check = checks.find((c) => c.name === 'claude-code-permissions'); + expect(check, 'claude-code-permissions check is not registered').toBeDefined(); + return check!; +} + +describe('doctor: claude-code-permissions', () => { + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'wigolo-doctor-perms-')); + mkdirSync(join(tmpHome, '.wigolo'), { recursive: true }); + vi.resetModules(); + }); + afterEach(() => { + try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('skips when Claude Code is not installed', async () => { + const check = await permissionCheck(); + expect(check.status).toBe('skipped'); + expect(check.detail).toMatch(/not detected/); + }); + + it('reports the missing rule as advisory, never as a failure', async () => { + // A user who prefers to approve every call is not running a broken install, + // so this must never colour doctor's exit code. + writeSettings({ permissions: { allow: ['WebFetch'] } }); + + const check = await permissionCheck(); + + expect(check.status).toBe('skipped'); + expect(check.detail).toContain('mcp__wigolo__*'); + // The restart is the step users miss; the detail has to say so. + expect(check.detail).toMatch(/restart Claude Code/i); + }); + + it('is never fixable — doctor --fix must not write to another app\'s config', async () => { + writeSettings({ permissions: { allow: ['WebFetch'] } }); + expect((await permissionCheck()).fixable).toBe(false); + + writeSettings({ permissions: { allow: ['mcp__wigolo__*'] } }); + expect((await permissionCheck()).fixable).toBe(false); + }); + + it('passes on the wildcard rule', async () => { + writeSettings({ permissions: { allow: ['mcp__wigolo__*'] } }); + expect((await permissionCheck()).status).toBe('ok'); + }); + + it('passes on the bare server rule', async () => { + writeSettings({ permissions: { allow: ['mcp__wigolo'] } }); + expect((await permissionCheck()).status).toBe('ok'); + }); + + it('passes when every tool is listed literally', async () => { + const literal = [ + 'fetch', 'search', 'crawl', 'cache', 'extract', + 'find_similar', 'research', 'agent', 'diff', 'watch', + ].map((t) => `mcp__wigolo__${t}`); + writeSettings({ permissions: { allow: literal } }); + + expect((await permissionCheck()).status).toBe('ok'); + }); + + it('does not report ok when a deny rule shadows the allow rule', async () => { + // Claude Code evaluates deny before allow, so the tools are blocked + // despite the allow entry. + writeSettings({ permissions: { allow: ['mcp__wigolo__*'], deny: ['mcp__wigolo__*'] } }); + + const check = await permissionCheck(); + + expect(check.status).toBe('skipped'); + expect(check.detail).toMatch(/deny rule/); + }); + + it('does not report ok when an ask rule shadows the allow rule', async () => { + writeSettings({ permissions: { allow: ['mcp__wigolo__*'], ask: ['mcp__wigolo__search'] } }); + + const check = await permissionCheck(); + + expect(check.status).toBe('skipped'); + expect(check.detail).toMatch(/ask rule/); + }); + + it('ignores deny/ask rules aimed at other servers', async () => { + writeSettings({ + permissions: { + allow: ['mcp__wigolo__*'], + deny: ['mcp__other__*', 'Read(**/.env)'], + ask: ['Bash(rm:*)'], + }, + }); + + expect((await permissionCheck()).status).toBe('ok'); + }); + + it('is not satisfied by a partial literal list', async () => { + writeSettings({ permissions: { allow: ['mcp__wigolo__search', 'mcp__wigolo__fetch'] } }); + expect((await permissionCheck()).status).toBe('skipped'); + }); + + it('reports an unreadable settings file instead of throwing', async () => { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync(join(tmpHome, '.claude', 'settings.json'), '{ broken', 'utf-8'); + + const check = await permissionCheck(); + + expect(check.status).toBe('skipped'); + expect(check.detail).toMatch(/unreadable/); + }); + + it('writes nothing — the check is read-only', async () => { + const original = { permissions: { allow: ['WebFetch'] } }; + writeSettings(original); + const before = readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8'); + + await permissionCheck(); + + expect(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')).toBe(before); + }); +}); diff --git a/tests/unit/cli/tui/flags.test.ts b/tests/unit/cli/tui/flags.test.ts index 926dd2976..80c88ce20 100644 --- a/tests/unit/cli/tui/flags.test.ts +++ b/tests/unit/cli/tui/flags.test.ts @@ -76,6 +76,14 @@ describe('parseInitFlags — flags', () => { expect(parseInitFlags(['-h']).help).toBe(true); }); + it('recognizes --no-permissions and its explicit-on alias', () => { + expect(parseInitFlags([]).permissions).toBe(true); + expect(parseInitFlags(['--no-permissions']).permissions).toBe(false); + expect(parseInitFlags(['--permissions']).permissions).toBe(true); + // Last flag wins, matching how --warmup/--no-warmup already behave. + expect(parseInitFlags(['--no-permissions', '--permissions']).permissions).toBe(true); + }); + it('combines flags in any order', () => { const out = parseInitFlags(['--plain', '--agents=cursor', '-y', '--skip-verify']); expect(out).toEqual({ @@ -88,6 +96,8 @@ describe('parseInitFlags — flags', () => { wizard: false, // Full setup is the default: warmup is TRUE unless --no-warmup is passed. warmup: true, + // Same shape: allowing the tools is on unless --no-permissions is passed. + permissions: true, json: false, provider: undefined, search: undefined, diff --git a/tests/unit/server/tool-annotations.test.ts b/tests/unit/server/tool-annotations.test.ts new file mode 100644 index 000000000..d33cea374 --- /dev/null +++ b/tests/unit/server/tool-annotations.test.ts @@ -0,0 +1,226 @@ +/** + * MCP capability-hint coverage for `tools/list`. + * + * Hosts decide whether a tool call needs a permission prompt from these + * annotations, so a tool that ships without them prompts on every call. The + * exact matrix is pinned here: adding an eleventh tool without annotating it + * fails this suite rather than silently regressing the permission story in + * every host wigolo installs into. + * + * The two non-read-only rows are the point of the test. `cache` accepts + * `clear` and `watch` accepts `create`/`delete`, so claiming either is + * read-only would be a lie a host would act on. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { resetConfig } from '../../../src/config.js'; +import { _resetMigrationGuard } from '../../../src/cache/migrations/runner.js'; + +vi.mock('../../../src/cache/db.js', async () => { + const actual = await vi.importActual( + '../../../src/cache/db.js', + ); + return { + ...actual, + initDatabase: (_path?: string) => actual.initDatabase(':memory:'), + }; +}); + +vi.mock('../../../src/fetch/browser-pool.js', () => { + class MockMultiBrowserPool { + shutdown = vi.fn().mockResolvedValue(undefined); + fetchWithBrowser = vi.fn(); + getConfiguredTypes = vi.fn().mockReturnValue(['chromium']); + getStats = vi.fn().mockReturnValue([]); + } + return { + MultiBrowserPool: MockMultiBrowserPool, + BrowserPool: class MockBrowserPool extends MockMultiBrowserPool { + acquire = vi.fn(); + release = vi.fn(); + }, + }; +}); + +vi.mock('../../../src/fetch/http-client.js', () => ({ + httpFetch: vi.fn(), +})); + +vi.mock('../../../src/fetch/router.js', () => ({ + SmartRouter: class MockSmartRouter { + constructor(_httpClient: unknown, _browserPool: unknown) {} + fetch = vi.fn(); + getDomainStats = vi.fn(); + }, +})); + +vi.mock('../../../src/searxng/bootstrap.js', () => ({ + resolveSearchBackend: vi.fn().mockResolvedValue({ type: 'scraping' }), + bootstrapNativeSearxng: vi.fn(), + getBootstrapState: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../src/searxng/process.js', () => ({ + SearxngProcess: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(null), + stop: vi.fn().mockResolvedValue(undefined), + getUrl: vi.fn().mockReturnValue(null), + })), +})); + +vi.mock('../../../src/searxng/docker.js', () => ({ + DockerSearxng: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(null), + stop: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('../../../src/embedding/embed.js', () => ({ + getEmbeddingService: () => ({ + init: vi.fn().mockResolvedValue(undefined), + isAvailable: () => false, + shutdown: vi.fn(), + }), + resetEmbeddingService: vi.fn(), +})); + +const HINT_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const; + +type HintMatrix = Record<(typeof HINT_KEYS)[number], boolean>; + +// Three tools are not read-only, and none of them look it from the name alone: +// `fetch` — `actions` runs live click/type via Playwright +// `cache` — `clear` deletes rows +// `watch` — create/delete mutate the job store +// `diff` is the only closed-world tool: it resolves its `url` sides from the +// local cache and returns `cache_miss` rather than fetching (src/tools/diff.ts). +// `cache` looks local but `check_changes` re-fetches over the network +// (src/tools/cache.ts). +const EXPECTED: Record = { + fetch: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + search: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + crawl: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + cache: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + extract: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + find_similar: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + research: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + agent: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + diff: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + watch: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, +}; + +async function connectClient() { + const { initSubsystems, createMcpServer } = await import('../../../src/server.js'); + const subs = await initSubsystems(); + const server = createMcpServer(subs); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0' }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + const teardown = async () => { + await client.close(); + await server.close(); + await subs.shutdown(); + }; + + return { client, teardown }; +} + +describe('tools/list capability annotations', () => { + let tmpDataDir: string; + + beforeEach(() => { + tmpDataDir = mkdtempSync(join(tmpdir(), 'wigolo-tool-annotations-')); + process.env.WIGOLO_DATA_DIR = tmpDataDir; + // `pluginsDir` defaults to `/plugins`, so the line above already + // isolates it — but pin it anyway so an exported WIGOLO_PLUGINS_DIR in the + // developer's shell can't make `initSubsystems()` import real plugin code. + process.env.WIGOLO_PLUGINS_DIR = join(tmpDataDir, 'plugins'); + resetConfig(); + _resetMigrationGuard(); + vi.clearAllMocks(); + }); + afterEach(() => { + delete process.env.WIGOLO_DATA_DIR; + delete process.env.WIGOLO_PLUGINS_DIR; + resetConfig(); + try { rmSync(tmpDataDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('every tool carries annotations with a non-empty title', async () => { + const { client, teardown } = await connectClient(); + try { + const res = await client.listTools(); + expect(res.tools).toHaveLength(Object.keys(EXPECTED).length); + for (const tool of res.tools) { + expect(tool.annotations, `${tool.name} has no annotations`).toBeDefined(); + expect(typeof tool.annotations?.title).toBe('string'); + expect((tool.annotations?.title as string).length).toBeGreaterThan(0); + } + } finally { + await teardown(); + } + }); + + it('every hint is an explicit boolean, never left undefined', async () => { + // An absent hint is not the same as `false`: the spec lets a host fall + // back to its own default, which is what makes tools prompt. + const { client, teardown } = await connectClient(); + try { + const res = await client.listTools(); + for (const tool of res.tools) { + for (const hint of HINT_KEYS) { + expect( + typeof tool.annotations?.[hint], + `${tool.name}.${hint} is not a boolean`, + ).toBe('boolean'); + } + } + } finally { + await teardown(); + } + }); + + it('matches the pinned hint matrix', async () => { + const { client, teardown } = await connectClient(); + try { + const res = await client.listTools(); + const actual = Object.fromEntries( + res.tools.map((t) => [ + t.name, + { + readOnlyHint: t.annotations?.readOnlyHint, + destructiveHint: t.annotations?.destructiveHint, + idempotentHint: t.annotations?.idempotentHint, + openWorldHint: t.annotations?.openWorldHint, + }, + ]), + ); + expect(actual).toEqual(EXPECTED); + } finally { + await teardown(); + } + }); + + it('the three state-changing tools are not advertised as read-only', async () => { + const { client, teardown } = await connectClient(); + try { + const res = await client.listTools(); + const notReadOnly = res.tools + .filter((t) => t.annotations?.readOnlyHint === false) + .map((t) => t.name) + .sort(); + expect(notReadOnly).toEqual(['cache', 'fetch', 'watch']); + } finally { + await teardown(); + } + }); +});