mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 00:53:13 +00:00
feat(core): add a websearch.use policy action (#61557)
This commit is contained in:
parent
ffe6fed583
commit
baeebc2f93
10 changed files with 108 additions and 10 deletions
2
packages/client/src/promise/generated/types.ts
generated
2
packages/client/src/promise/generated/types.ts
generated
|
|
@ -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"
|
||||
}>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)({
|
||||
|
|
|
|||
|
|
@ -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<void>)[] = []
|
||||
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<void>)
|
||||
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
|
||||
|
|
|
|||
6
packages/protocol/openapi.json
generated
6
packages/protocol/openapi.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ resource: <integration ID>#<account>, 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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue