diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebd..86cb284693e 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebd..86cb284693e 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 98c7ece1052..efc303062f2 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -7,6 +7,8 @@ import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" import { Session } from "../../session" +import { Agent } from "../../agent/agent" +import { PermissionNext } from "../../permission/next" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -33,8 +35,31 @@ export const ExperimentalRoutes = lazy(() => ...errors(400), }, }), + validator( + "query", + z.object({ + provider: z.string().optional(), + model: z.string().optional(), + agent: z.string().optional(), + sessionID: z.string().optional(), + }), + ), async (c) => { - return c.json(await ToolRegistry.ids()) + const { provider, model, agent: agentName, sessionID } = c.req.valid("query") + if (!provider || !model) { + return c.json(await ToolRegistry.ids()) + } + + const agent = await Agent.get(agentName ?? (await Agent.defaultAgent())) + const sessionPermission = sessionID ? ((await Session.get(sessionID)).permission ?? []) : [] + + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }, agent) + const disabled = PermissionNext.disabled( + tools.map((tool) => tool.id), + PermissionNext.merge(agent.permission, sessionPermission), + ) + + return c.json(tools.filter((tool) => !disabled.has(tool.id)).map((tool) => tool.id)) }, ) .get( @@ -73,18 +98,30 @@ export const ExperimentalRoutes = lazy(() => z.object({ provider: z.string(), model: z.string(), + agent: z.string().optional(), + sessionID: z.string().optional(), }), ), async (c) => { - const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) + const { provider, model, agent: agentName, sessionID } = c.req.valid("query") + const agent = await Agent.get(agentName ?? (await Agent.defaultAgent())) + const sessionPermission = sessionID ? ((await Session.get(sessionID)).permission ?? []) : [] + + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }, agent) + const disabled = PermissionNext.disabled( + tools.map((tool) => tool.id), + PermissionNext.merge(agent.permission, sessionPermission), + ) + return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, - })), + tools + .filter((tool) => !disabled.has(tool.id)) + .map((t) => ({ + id: t.id, + description: t.description, + // Handle both Zod schemas and plain JSON schemas + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, + })), ) }, ) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2ec..4bf84f284a7 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -32,6 +32,7 @@ export namespace LLM { sessionID: string model: Provider.Model agent: Agent.Info + sessionPermission?: PermissionNext.Ruleset system: string[] abort: AbortSignal messages: ModelMessage[] @@ -255,8 +256,9 @@ export namespace LLM { }) } - async function resolveTools(input: Pick) { - const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) + async function resolveTools(input: Pick) { + const ruleset = PermissionNext.merge(input.agent.permission, input.sessionPermission ?? []) + const disabled = PermissionNext.disabled(Object.keys(input.tools), ruleset) for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { delete input.tools[tool] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 655afd2b14d..9cfa2b0fe17 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -659,6 +659,7 @@ export namespace SessionPrompt { const result = await processor.process({ user: lastUser, agent, + sessionPermission: session.permission, abort, sessionID, system, diff --git a/packages/opencode/test/server/experimental-tools.test.ts b/packages/opencode/test/server/experimental-tools.test.ts new file mode 100644 index 00000000000..e42f182bfb9 --- /dev/null +++ b/packages/opencode/test/server/experimental-tools.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import { Hono } from "hono" +import { ExperimentalRoutes } from "../../src/server/routes/experimental" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +function route() { + return new Hono().route("/experimental", ExperimentalRoutes()) +} + +async function listTools(query: Record) { + const qs = new URLSearchParams(query) + const res = await route().request(`/experimental/tool?${qs.toString()}`) + expect(res.status).toBe(200) + return (await res.json()) as Array<{ id: string }> +} + +async function listToolIDs(query: Record) { + const qs = new URLSearchParams(query) + const suffix = qs.toString() + const res = await route().request(`/experimental/tool/ids${suffix ? `?${suffix}` : ""}`) + expect(res.status).toBe(200) + return (await res.json()) as string[] +} + +describe("ExperimentalRoutes /experimental/tool", () => { + test("applies agent permissions when listing visible tools", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const buildTools = await listTools({ provider: "openai", model: "qwen-plus", agent: "build" }) + const exploreTools = await listTools({ provider: "openai", model: "qwen-plus", agent: "explore" }) + + expect(buildTools.some((tool) => tool.id === "edit")).toBe(true) + expect(exploreTools.some((tool) => tool.id === "edit")).toBe(false) + }, + }) + }) + + test("applies session permissions when sessionID is provided", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await Session.setPermission({ + sessionID: session.id, + permission: [{ permission: "bash", pattern: "*", action: "deny" }], + }) + + const withSession = await listTools({ + provider: "openai", + model: "qwen-plus", + agent: "build", + sessionID: session.id, + }) + + expect(withSession.some((tool) => tool.id === "bash")).toBe(false) + }, + }) + }) +}) + +describe("ExperimentalRoutes /experimental/tool/ids", () => { + test("applies agent permissions when listing visible tool IDs", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const buildToolIDs = await listToolIDs({ provider: "openai", model: "qwen-plus", agent: "build" }) + const exploreToolIDs = await listToolIDs({ provider: "openai", model: "qwen-plus", agent: "explore" }) + + expect(buildToolIDs.includes("edit")).toBe(true) + expect(exploreToolIDs.includes("edit")).toBe(false) + }, + }) + }) + + test("applies session permissions to tool IDs when sessionID is provided", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await Session.setPermission({ + sessionID: session.id, + permission: [{ permission: "bash", pattern: "*", action: "deny" }], + }) + + const withSession = await listToolIDs({ + provider: "openai", + model: "qwen-plus", + agent: "build", + sessionID: session.id, + }) + + expect(withSession.includes("bash")).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index a89a00ebc05..bbf21875bec 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import type { ModelMessage } from "ai" +import { jsonSchema, type ModelMessage, tool } from "ai" import { LLM } from "../../src/session/llm" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" @@ -221,6 +221,125 @@ function createEventResponse(chunks: unknown[], includeDone = false) { } describe("session.llm.stream", () => { + test("applies session permissions before exposing tools to the model", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + + const allowedRequest = waitRequest( + "/chat/completions", + new Response(createChatStream("allowed"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + const deniedRequest = waitRequest( + "/chat/completions", + new Response(createChatStream("denied"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(providerID, fixture.model.id) + const sessionID = "session-test-session-permissions" + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: "user-permission-1", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID, modelID: resolved.id }, + } satisfies MessageV2.User + + const bashTool = tool({ + description: "Run shell commands", + inputSchema: jsonSchema({ + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + additionalProperties: false, + }), + execute: async () => ({ output: "", title: "", metadata: {} }), + }) + + const allowedStream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: { bash: bashTool }, + }) + for await (const _ of allowedStream.fullStream) { + } + + const allowedCapture = await allowedRequest + const allowedTools = allowedCapture.body.tools as Array<{ function?: { name?: string } }> | undefined + expect(Array.isArray(allowedTools)).toBe(true) + expect(allowedTools?.some((item) => item.function?.name === "bash")).toBe(true) + + const deniedStream = await LLM.stream({ + user: { ...user, id: "user-permission-2" }, + sessionID, + model: resolved, + agent, + sessionPermission: [{ permission: "bash", pattern: "*", action: "deny" }], + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello again" }], + tools: { bash: bashTool }, + }) + for await (const _ of deniedStream.fullStream) { + } + + const deniedCapture = await deniedRequest + const deniedTools = deniedCapture.body.tools as Array<{ function?: { name?: string } }> | undefined + expect(deniedTools?.some((item) => item.function?.name === "bash") ?? false).toBe(false) + }, + }) + }) + test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => { const server = state.server if (!server) { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe98..eb2a891a8af 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1953,6 +1953,10 @@ export type ToolIdsData = { path?: never query?: { directory?: string + provider?: string + model?: string + agent?: string + sessionID?: string } url: "/experimental/tool/ids" } @@ -1982,6 +1986,8 @@ export type ToolListData = { directory?: string provider: string model: string + agent?: string + sessionID?: string } url: "/experimental/tool" } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2bb2edcd175..8e75753213e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -837,6 +837,10 @@ export class Tool extends HeyApiClient { parameters?: { directory?: string workspace?: string + provider?: string + model?: string + agent?: string + sessionID?: string }, options?: Options, ) { @@ -847,6 +851,10 @@ export class Tool extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + { in: "query", key: "agent" }, + { in: "query", key: "sessionID" }, ], }, ], @@ -869,6 +877,8 @@ export class Tool extends HeyApiClient { workspace?: string provider: string model: string + agent?: string + sessionID?: string }, options?: Options, ) { @@ -881,6 +891,8 @@ export class Tool extends HeyApiClient { { in: "query", key: "workspace" }, { in: "query", key: "provider" }, { in: "query", key: "model" }, + { in: "query", key: "agent" }, + { in: "query", key: "sessionID" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a47b18db219..c162193c7c7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2415,6 +2415,10 @@ export type ToolIdsData = { query?: { directory?: string workspace?: string + provider?: string + model?: string + agent?: string + sessionID?: string } url: "/experimental/tool/ids" } @@ -2445,6 +2449,8 @@ export type ToolListData = { workspace?: string provider: string model: string + agent?: string + sessionID?: string } url: "/experimental/tool" }