From baeebc2f93dcb38834f76eef7a0fd9932bf63699 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:13:25 -0600 Subject: [PATCH] feat(core): add a websearch.use policy action (#61557) --- .../client/src/promise/generated/types.ts | 2 ++ packages/core/src/config/plugin/policy.ts | 6 +++- packages/core/src/tool/plugin/websearch.ts | 3 +- packages/core/test/config/policy.test.ts | 24 ++++++++++++++ packages/core/test/tool-websearch.test.ts | 31 ++++++++++++++++++- packages/protocol/openapi.json | 6 ++-- packages/schema/src/config/policy.ts | 2 ++ packages/schema/test/config.test.ts | 10 ++++++ services/www/src/docs/content/policies.mdx | 30 +++++++++++++++--- specs/v2/provider-policy.md | 4 +++ 10 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 746a64c51abf..fbbff6082526 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2387,6 +2387,7 @@ export type ConfigEntry = | "mcp.use" | "skill.use" | "plugin.use" + | "websearch.use" resource: string effect: "allow" | "deny" }> @@ -2558,6 +2559,7 @@ export type ConfigEntry = | "mcp.use" | "skill.use" | "plugin.use" + | "websearch.use" resource: string effect: "allow" | "deny" }> diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 27463dbb9bd1..2e740628a7ff 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -76,7 +76,11 @@ export const Plugin = define({ yield* websearch.transform((editor) => { const statements = policies() for (const provider of editor.list()) { - if (integrationDenied(statements, provider.id)) editor.remove(provider.id) + if ( + ManagedPolicy.decision(statements, "websearch.use", provider.id) === "deny" || + integrationDenied(statements, provider.id) + ) + editor.remove(provider.id) } }) yield* ctx.mcp.transform((servers) => { diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index 19d7ae5eaf87..d3e2c91997b0 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -185,7 +185,8 @@ export const Plugin = { Effect.as(false), Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)), ) - if (disabled) delete event.tools[name] + // Policy can remove every provider, and the model shouldn't be offered a tool that can't search. + if (disabled || (yield* websearch.providers()).length === 0) delete event.tools[name] }) yield* ctx.session.hook("context", hook) yield* ctx.session.hook("compaction", hook) diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 207efd4fdbe9..a9f9cc9d16a1 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -14,7 +14,10 @@ import { Session } from "@opencode/core/session" import { Credential } from "@opencode/core/credential" import { ConfigIntegration } from "@opencode/core/config/integration" import { WebSearch } from "@opencode/core/websearch" +import { WebSearchExa } from "@opencode/core/plugin/websearch/exa" +import { WebSearchTinyFish } from "@opencode/core/plugin/websearch/tinyfish" import { Effect, Schema } from "effect" +import { FetchHttpClient } from "effect/http" import { waitUntil } from "../lib/eventually" import { testEffect } from "../lib/effect" import { organizationPolicy } from "../fixture/policy" @@ -385,6 +388,27 @@ describe("integration policy", () => { }), ) + it.effect("removes built-in and plugin web search providers that websearch.use denies", () => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const host = yield* PluginHost.make(yield* Plugin.Service, "test") + // TinyFish needs no API key, so it is offered until a policy removes it. + yield* Effect.all([WebSearchExa.Plugin.effect(host), WebSearchTinyFish.Plugin.effect(host)]).pipe( + Effect.provide(FetchHttpClient.layer), + ) + yield* host.websearch.transform((editor) => + editor.add({ id: WebSearch.ID.make("acme-search"), name: "Acme Search", execute: () => Effect.succeed([]) }), + ) + yield* addPlugin([ + document( + { action: "websearch.use", resource: "*", effect: "deny" }, + { action: "websearch.use", resource: "exa", effect: "allow" }, + ), + ]) + expect((yield* websearch.providers()).map((item): string => item.id)).toEqual(["exa"]) + }), + ) + it.effect("converts legacy statements in Console config before enforcement sees them", () => Effect.sync(() => { const remote = Schema.decodeUnknownSync(ConfigIntegration.Remote)({ diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index c3f1fcf5d238..7c32adcd86aa 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -19,13 +19,25 @@ import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { webSearchHost } from "./plugin/host" import { TestWebSearch } from "./lib/websearch" +import type { SessionContext } from "@opencode/plugin/effect/session" +// The plugin's `context` hook, which edits the tools offered to the model. +const contextHooks: ((event: SessionContext) => Effect.Effect)[] = [] const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", layer: Layer.effectDiscard( Effect.gen(function* () { const websearch = yield* WebSearch.Service - yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) }) + yield* registerToolPlugin(WebSearchTool.Plugin, { + websearch: webSearchHost(websearch), + session: { + hook: (name, callback) => + Effect.sync(() => { + if (name === "context") contextHooks.push(callback as (event: SessionContext) => Effect.Effect) + return { dispose: Effect.void } + }), + }, + }) }), ), deps: [Tool.node, Permission.node, WebSearch.node, Form.node], @@ -348,6 +360,23 @@ describe("WebSearchTool registration", () => { }), ) + it.effect("offers the tool to the model only while a provider remains", () => + Effect.gen(function* () { + contextHooks.length = 0 + const fixture = yield* setup + const offered = Effect.fnUntraced(function* () { + const event = { tools: { [WebSearchTool.name]: { description: "", input: {} } } } + yield* Effect.forEach(contextHooks, (hook) => hook(event as unknown as SessionContext)) + return WebSearchTool.name in event.tools + }) + expect(yield* offered()).toBe(true) + + // As when policy denies every provider. + yield* fixture.websearch.transform((editor) => editor.list().forEach((provider) => editor.remove(provider.id))) + expect(yield* offered()).toBe(false) + }), + ) + it.effect("persists the choice to disable web search", () => Effect.gen(function* () { const fixture = yield* setup diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 3bd978216e32..edb5909bc843 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -16376,7 +16376,8 @@ "integration.use", "mcp.use", "skill.use", - "plugin.use" + "plugin.use", + "websearch.use" ] }, "resource": { @@ -16739,7 +16740,8 @@ "integration.use", "mcp.use", "skill.use", - "plugin.use" + "plugin.use", + "websearch.use" ] }, "resource": { diff --git a/packages/schema/src/config/policy.ts b/packages/schema/src/config/policy.ts index 736c4544810b..2124c7102dd7 100644 --- a/packages/schema/src/config/policy.ts +++ b/packages/schema/src/config/policy.ts @@ -13,6 +13,7 @@ export type Effect = typeof Effect.Type * web search, MCP servers, or config an integration supplies come from no account (env keys, local config, the * signed-out free tier). * - `mcp.use`, `skill.use`, `plugin.use`: resource is an MCP server name, skill ID, or plugin target. + * - `websearch.use`: resource is a web search provider ID, such as `opencode` or `exa`. */ export const Action = Schema.Literals([ "provider.use", @@ -22,6 +23,7 @@ export const Action = Schema.Literals([ "mcp.use", "skill.use", "plugin.use", + "websearch.use", ]) export type Action = typeof Action.Type diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 3ac721fb2e24..0eb35d734291 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -30,6 +30,16 @@ describe("Config.Entry", () => { ).toThrow() }) + test("round-trips websearch.use statements", () => { + const input = { + policies: [ + { action: "websearch.use", resource: "*", effect: "deny" }, + { action: "websearch.use", resource: "opencode", effect: "allow" }, + ], + } as const + expect(Schema.encodeSync(Config.Info)(Schema.decodeUnknownSync(Config.Info)(input))).toEqual(input) + }) + test("normalizes legacy prefixed integration.use statements into mcp.use, skill.use and plugin.use", () => { const decode = Schema.decodeUnknownSync(Schema.Array(ConfigPolicy.Info)) expect( diff --git a/services/www/src/docs/content/policies.mdx b/services/www/src/docs/content/policies.mdx index bfd551cff6a8..6957f016b21c 100644 --- a/services/www/src/docs/content/policies.mdx +++ b/services/www/src/docs/content/policies.mdx @@ -40,11 +40,11 @@ merges into `policies`: Each statement has three fields: -| Field | Values | Meaning | -| ---------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| `action` | `provider.use`, `tool.use`, `integration.connect`, `integration.use`, `mcp.use`, `skill.use`, `plugin.use` | The operation being controlled | -| `resource` | string or wildcard pattern | What the statement applies to; depends on the action | -| `effect` | `allow`, `deny` | The decision when this statement matches | +| Field | Values | Meaning | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `action` | `provider.use`, `tool.use`, `integration.connect`, `integration.use`, `mcp.use`, `skill.use`, `plugin.use`, `websearch.use` | The operation being controlled | +| `resource` | string or wildcard pattern | What the statement applies to; depends on the action | +| `effect` | `allow`, `deny` | The decision when this statement matches | Statements that fail validation are dropped with a warning in the server log and the rest of the list still applies. Check the log after editing a `deny`. @@ -247,6 +247,26 @@ Clients older than 2.2.3 ignore `integration.connect`, `integration.use` (in its meaning), `mcp.use`, `skill.use`, and `plugin.use`, so require 2.2.3 or newer wherever you rely on them. +## Web search + +`websearch.use` controls the web search providers the `websearch` tool can use. The +resource is the provider ID: `opencode` for OpenCode Web Search from the Console, a +built-in provider such as `exa`, `firecrawl`, `parallel`, `tavily`, or `tinyfish`, or +the ID of a provider a plugin adds. + +```jsonc +{ + "policies": [ + { "action": "websearch.use", "resource": "*", "effect": "deny" }, + { "action": "websearch.use", "resource": "opencode", "effect": "allow" }, + ], +} +``` + +Denied providers are removed even when they work without an API key, such as Exa and +TinyFish. When every provider is denied, the `websearch` tool is no longer offered. +Clients older than 2.2.6 ignore `websearch.use`. + ## Precedence Ordinary settings let the nearest configuration win. Policies reverse that: statements diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index a81c56b6674c..f41119df7972 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -24,6 +24,9 @@ resource: #, or the bare integration ID without an acco action: mcp.use | skill.use | plugin.use resource: MCP server name, skill ID, or unversioned package / absolute local path +action: websearch.use +resource: web search provider ID, such as opencode, exa, or tinyfish + Legacy integration.use statements with an mcp:, skill:, or plugin: prefix are converted to the matching *.use action when ConfigPolicy.Info decodes them. ``` @@ -78,6 +81,7 @@ interface PolicyInfo { | "mcp.use" | "skill.use" | "plugin.use" + | "websearch.use" resource: string } ```