Skip to content
Merged
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
22 changes: 2 additions & 20 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
export * as Agent from "./agent.js"

import path from "path"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Global } from "@opencode/util/global"
import { Bus } from "./bus.js"
import { State } from "./state.js"

const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")

export const ID = Agent.ID
export type ID = typeof ID.Type
export const Name = Agent.Name
Expand Down Expand Up @@ -55,13 +50,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const global = yield* Global.Service
const permissions: Info["permissions"] = [
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Editor>({
name: "agent",
initial: () => ({ agents: new Map() }),
Expand All @@ -72,13 +60,7 @@ const layer = Layer.effect(
editor.default = id
},
update: (id, fn) => {
const defaults = Info.default(id)
const current =
editor.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
const current = editor.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
if (!editor.agents.has(id)) editor.agents.set(id, current)
fn(current)
current.id = id
Expand Down Expand Up @@ -131,4 +113,4 @@ const layer = Layer.effect(
}),
)

export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
32 changes: 12 additions & 20 deletions packages/core/src/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export * as AgentPlugin from "./agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"

const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.

Expand Down Expand Up @@ -97,31 +96,24 @@ export const Plugin = define({
})

editor.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
item.name = Agent.Name.make("Explore")
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
item.system = PROMPT_EXPLORE
item.mode = "subagent"
item.permissions.push(
...Permission.merge(
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" },
],
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
),
{ action: "*", resource: "*", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" },
{ action: "external_directory", resource: "*", effect: "allow" },
)
})

Expand Down
1 change: 0 additions & 1 deletion packages/core/src/plugin/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
item.permissions.push({ action: "edit", resource: "*", effect: "deny" })
item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" })
item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" })
})
})

Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe("Agent", () => {
}),
)

it.effect("applies managed external directories without opting built-in agents into bash", () =>
it.effect("allows external directories without opting built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect(
Expand Down Expand Up @@ -202,13 +202,15 @@ describe("Agent", () => {
).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "/outside/*", permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "/outside/*", explore?.permissions ?? []).effect).toBe("allow")
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
Expand Down
22 changes: 7 additions & 15 deletions packages/core/test/config/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,7 @@ function migrateV1(input: unknown) {
if (result.type !== "normalized") throw new Error("expected normalized config")
return result.encoded
}
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const defaultPermissions = Agent.Info.default(Agent.ID.make("test")).permissions

test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
Expand Down Expand Up @@ -229,7 +223,7 @@ permissions:
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
expect(build.permissions).toEqual([
...defaultPermissions(global),
...defaultPermissions,
{ action: "question", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "ask" },
Expand Down Expand Up @@ -260,7 +254,6 @@ permissions:
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) =>
editor.update(build, (agent) => {
Expand Down Expand Up @@ -313,7 +306,7 @@ permissions:
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
...defaultPermissions(global),
...defaultPermissions,
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
Expand All @@ -331,15 +324,15 @@ permissions:
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
...defaultPermissions(global),
...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
])
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
...defaultPermissions(global),
...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
Expand Down Expand Up @@ -471,7 +464,6 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [
new Document({
type: "document",
Expand All @@ -489,13 +481,13 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
Expand Down
20 changes: 5 additions & 15 deletions packages/core/test/plugin/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,10 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
const persisted = new Array<string>()
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect<void>) | undefined
const defaults = Agent.Info.default(plan)
const planAgent = {
id: plan,
name: Agent.Name.make("Plan"),
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
],
...defaults,
permissions: [...defaults.permissions],
} satisfies Types.DeepMutable<Agent.Info>
const driver = Environment.makeMemoryDriver()
yield* PlanPlugin.Plugin.effect(
Expand Down Expand Up @@ -263,17 +257,13 @@ describe("plan plugin mutations", () => {
}),
)

it.effect("allows the Plan directory external boundary", () =>
it.effect("allows external directories without asking", () =>
Effect.gen(function* () {
const { planAgent } = yield* run()
expect(
Permission.evaluate("external_directory", path.join(planDirectory, "*"), planAgent.permissions).effect,
).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(planDirectory, "nested", "*"), planAgent.permissions)
.effect,
).toBe("allow")
expect(Permission.evaluate("external_directory", "/outside/*", planAgent.permissions).effect).toBe("ask")
expect(Permission.evaluate("external_directory", "/outside/*", planAgent.permissions).effect).toBe("allow")
}),
)

Expand Down
1 change: 0 additions & 1 deletion packages/schema/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export const Info = Schema.Struct({
hidden: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
Expand Down
Loading