mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 07:53:28 +00:00
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { Effect, Schema } from "effect"
|
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
import { Config } from "@opencode-ai/core/config"
|
|
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
|
import { tmpdir } from "../fixture/tmpdir"
|
|
import { testEffect } from "../lib/effect"
|
|
import { agentHost, host } from "../plugin/host"
|
|
|
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
|
|
const decode = Schema.decodeUnknownSync(Config.Info)
|
|
const defaultPermissions = [
|
|
{ action: "*", resource: "*", effect: "allow" },
|
|
{ action: "external_directory", resource: "*", effect: "ask" },
|
|
] satisfies PermissionV2.Ruleset
|
|
|
|
describe("ConfigAgentPlugin.Plugin", () => {
|
|
it.effect("matches POSIX paths against home-relative permissions", () =>
|
|
Effect.gen(function* () {
|
|
const permissions = yield* loadHomePermissions("/home/test")
|
|
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
|
|
"allow",
|
|
)
|
|
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
|
|
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
|
|
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
|
|
expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
|
|
expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
|
|
expect(PermissionV2.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
|
|
}),
|
|
)
|
|
|
|
it.effect("matches Windows paths against home-relative permissions", () =>
|
|
Effect.gen(function* () {
|
|
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
|
expect(
|
|
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
|
).toBe("allow")
|
|
expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
|
|
"deny",
|
|
)
|
|
}),
|
|
)
|
|
|
|
it.effect("applies all global permissions before agent-specific permissions", () =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* AgentV2.Service
|
|
const build = AgentV2.ID.make("build")
|
|
yield* agents.transform((editor) =>
|
|
editor.update(build, (agent) => {
|
|
agent.mode = "primary"
|
|
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
|
|
}),
|
|
)
|
|
|
|
const config = Config.Service.of({
|
|
entries: () =>
|
|
Effect.succeed([
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({
|
|
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
|
agents: {
|
|
build: {
|
|
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
|
|
},
|
|
reviewer: {
|
|
model: "openrouter/openai/gpt-5",
|
|
description: "Review changes",
|
|
mode: "subagent",
|
|
permissions: [
|
|
{ action: "edit", resource: "*", effect: "deny" },
|
|
{ action: "read", resource: "*", effect: "deny" },
|
|
],
|
|
},
|
|
removed: { description: "Removed later" },
|
|
},
|
|
}),
|
|
}),
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({
|
|
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
|
agents: {
|
|
reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
|
|
removed: { disabled: true },
|
|
late: {
|
|
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
]),
|
|
})
|
|
|
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
Effect.provideService(Config.Service, config),
|
|
)
|
|
|
|
const buildAgent = yield* agents.get(build)
|
|
if (!buildAgent) throw new Error("expected configured build agent")
|
|
expect(buildAgent.permissions).toEqual([
|
|
...defaultPermissions,
|
|
{ action: "bash", resource: "*", effect: "allow" },
|
|
{ action: "bash", resource: "*", effect: "ask" },
|
|
{ action: "read", resource: "*", effect: "allow" },
|
|
{ action: "bash", resource: "git *", effect: "allow" },
|
|
])
|
|
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
|
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
|
|
|
|
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
|
if (!reviewer) throw new Error("expected configured reviewer agent")
|
|
expect(reviewer).toMatchObject({
|
|
description: "Review changes",
|
|
mode: "subagent",
|
|
hidden: true,
|
|
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
|
})
|
|
expect(reviewer.permissions).toEqual([
|
|
...defaultPermissions,
|
|
{ action: "bash", resource: "*", effect: "ask" },
|
|
{ action: "read", resource: "*", effect: "allow" },
|
|
{ action: "edit", resource: "*", effect: "deny" },
|
|
{ action: "read", resource: "*", effect: "deny" },
|
|
])
|
|
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
|
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
|
...defaultPermissions,
|
|
{ action: "bash", resource: "*", effect: "ask" },
|
|
{ action: "read", resource: "*", effect: "allow" },
|
|
{ action: "edit", resource: "*", effect: "allow" },
|
|
])
|
|
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* AgentV2.Service
|
|
const config = Config.Service.of({
|
|
entries: () =>
|
|
Effect.succeed([
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({
|
|
agents: {
|
|
reviewer: {
|
|
model: "anthropic/claude-sonnet",
|
|
system: "Review carefully.",
|
|
description: "Reviews changes",
|
|
mode: "subagent",
|
|
hidden: true,
|
|
color: "warning",
|
|
steps: 12,
|
|
request: {
|
|
headers: { first: "one", shared: "first" },
|
|
body: { enabled: true, profile: "review", effort: "medium" },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({
|
|
agents: {
|
|
reviewer: {
|
|
request: {
|
|
headers: { shared: "last", second: "two" },
|
|
body: { retries: 2, effort: "high" },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
]),
|
|
})
|
|
|
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
Effect.provideService(Config.Service, config),
|
|
)
|
|
|
|
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
|
if (!reviewer) throw new Error("expected configured reviewer agent")
|
|
expect(reviewer).toMatchObject({
|
|
system: "Review carefully.",
|
|
description: "Reviews changes",
|
|
mode: "subagent",
|
|
hidden: true,
|
|
color: "warning",
|
|
steps: 12,
|
|
model: { providerID: "anthropic", id: "claude-sonnet" },
|
|
})
|
|
expect(reviewer.request).toEqual({
|
|
settings: {},
|
|
headers: { first: "one", shared: "last", second: "two" },
|
|
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("removes a built-in agent disabled by configuration", () =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* AgentV2.Service
|
|
const build = AgentV2.ID.make("build")
|
|
yield* agents.transform((editor) => editor.update(build, () => {}))
|
|
|
|
const config = Config.Service.of({
|
|
entries: () =>
|
|
Effect.succeed([
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({ agents: { build: { disabled: true } } }),
|
|
}),
|
|
]),
|
|
})
|
|
|
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
Effect.provideService(Config.Service, config),
|
|
)
|
|
|
|
expect(yield* agents.get(build)).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.live("loads legacy file-based agents from config directories", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
|
|
await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(tmp.path, "agents", "reviewer.md"),
|
|
`---
|
|
model: openrouter/openai/gpt-5
|
|
description: Markdown description
|
|
temperature: 0.5
|
|
tools:
|
|
write: false
|
|
---
|
|
Review carefully.`,
|
|
)
|
|
await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
|
|
await fs.writeFile(
|
|
path.join(tmp.path, "agents", "native.md"),
|
|
`---
|
|
request:
|
|
headers:
|
|
x-agent: native
|
|
body:
|
|
effort: high
|
|
permissions:
|
|
- action: edit
|
|
resource: "*"
|
|
effect: deny
|
|
---
|
|
Use native v2 fields.`,
|
|
)
|
|
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
|
|
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
|
|
})
|
|
const agents = yield* AgentV2.Service
|
|
const config = Config.Service.of({
|
|
entries: () =>
|
|
Effect.succeed([
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
|
|
}),
|
|
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
|
]),
|
|
})
|
|
|
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
Effect.provideService(Config.Service, config),
|
|
)
|
|
|
|
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
|
model: { providerID: "openrouter", id: "openai/gpt-5" },
|
|
system: "Review carefully.",
|
|
description: "Markdown description",
|
|
request: { body: { temperature: 0.5 } },
|
|
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
|
})
|
|
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
|
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
|
system: "Use native v2 fields.",
|
|
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
|
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
|
})
|
|
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
|
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
|
|
function loadHomePermissions(home: string) {
|
|
return Effect.gen(function* () {
|
|
const agents = yield* AgentV2.Service
|
|
const build = AgentV2.ID.make("build")
|
|
yield* agents.transform((editor) => editor.update(build, () => {}))
|
|
const config = Config.Service.of({
|
|
entries: () =>
|
|
Effect.succeed([
|
|
new Config.Document({
|
|
type: "document",
|
|
info: decode(
|
|
ConfigMigrateV1.migrate({
|
|
permission: {
|
|
external_directory: {
|
|
"~/p/**": "allow",
|
|
"/some/~/path": "deny",
|
|
"$HOMELESS/**": "deny",
|
|
},
|
|
bash: {
|
|
"$HOME/private/**": "deny",
|
|
},
|
|
},
|
|
agent: {
|
|
build: {
|
|
permission: {
|
|
external_directory: {
|
|
"$HOME/cache/**": "deny",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
}),
|
|
]),
|
|
})
|
|
|
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
Effect.provideService(Config.Service, config),
|
|
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
|
)
|
|
|
|
const agent = yield* agents.get(build)
|
|
if (!agent) throw new Error("expected configured build agent")
|
|
return agent.permissions
|
|
})
|
|
}
|