Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/custom-elements.d.ts
2 changes: 1 addition & 1 deletion packages/enterprise/src/custom-elements.d.ts
55 changes: 46 additions & 9 deletions packages/opencode/src/server/routes/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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,
})),
)
},
)
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
sessionPermission?: PermissionNext.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
Expand Down Expand Up @@ -255,8 +256,9 @@ export namespace LLM {
})
}

async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user" | "sessionPermission">) {
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]
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export namespace SessionPrompt {
const result = await processor.process({
user: lastUser,
agent,
sessionPermission: session.permission,
abort,
sessionID,
system,
Expand Down
110 changes: 110 additions & 0 deletions packages/opencode/test/server/experimental-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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<string, string>) {
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)
},
})
})
})
121 changes: 120 additions & 1 deletion packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1953,6 +1953,10 @@ export type ToolIdsData = {
path?: never
query?: {
directory?: string
provider?: string
model?: string
agent?: string
sessionID?: string
}
url: "/experimental/tool/ids"
}
Expand Down Expand Up @@ -1982,6 +1986,8 @@ export type ToolListData = {
directory?: string
provider: string
model: string
agent?: string
sessionID?: string
}
url: "/experimental/tool"
}
Expand Down
Loading
Loading