fix: proper OpenCode v1.1.1 permission migration (#490)

* fix: implement proper version-aware permission format for OpenCode v1.1.1

- Rewrite permission-compat.ts with runtime version detection
- createAgentToolRestrictions() returns correct format per version
- v1.1.1+ uses permission format, older uses tools format
- Add migrateToolsToPermission/migratePermissionToTools helpers
- Update test suite for new API

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode

* fix: update all agents to use createAgentToolRestrictions()

- Replace hardcoded tools: { X: false } format with version-aware utility
- All agents now use createAgentToolRestrictions([...])
- Ensures compatibility with both old and new OpenCode versions

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode

* fix: add runtime migration for user agent configs in config-handler

Migrate tools/permission format in user/project/plugin agent configs
based on detected OpenCode version at load time.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-05 05:28:25 +09:00
committed by GitHub
parent 09f72e2902
commit 8f2209a138
9 changed files with 232 additions and 170 deletions

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
@@ -15,12 +16,14 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
export function createDocumentWriterAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions(["background_task"])
return {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent" as const,
model,
tools: { background_task: false },
...restrictions,
prompt: `<role>
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "opencode/grok-code"
@@ -24,14 +25,19 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
}
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"background_task",
])
return {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, background_task: false },
permission: { edit: "deny" as const },
...restrictions,
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
## Your Mission

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
@@ -21,12 +22,14 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
export function createFrontendUiUxEngineerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions(["background_task"])
return {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent" as const,
model,
tools: { background_task: false },
...restrictions,
prompt: `# Role: Designer-Turned-Developer
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
@@ -21,13 +22,19 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
}
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"background_task",
])
return {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
...restrictions,
prompt: `# THE LIBRARIAN
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash"
@@ -13,13 +14,20 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"bash",
"background_task",
])
return {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
...restrictions,
prompt: `You interpret media files that cannot be read as plain text.
Your job: examine the attached file and extract ONLY what was requested.

View File

@@ -1,6 +1,7 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "openai/gpt-5.2"
@@ -97,22 +98,28 @@ Organize your final answer in three tiers:
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`
export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"task",
"background_task",
])
const base = {
description:
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, task: false, background_task: false },
permission: { edit: "deny" as const },
...restrictions,
prompt: ORACLE_SYSTEM_PROMPT,
}
} as AgentConfig
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium", textVerbosity: "high" }
return { ...base, reasoningEffort: "medium", textVerbosity: "high" } as AgentConfig
}
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
}
export const oracleAgent = createOracleAgent()

View File

@@ -21,6 +21,7 @@ import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
import { createBuiltinMcps } from "../mcp";
import type { OhMyOpenCodeConfig } from "../config";
import { log } from "../shared";
import { migrateAgentConfig } from "../shared/permission-compat";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
import type { ModelCacheState } from "../plugin-state";
@@ -95,13 +96,32 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
config.model as string | undefined
);
const userAgents = (pluginConfig.claude_code?.agents ?? true)
const rawUserAgents = (pluginConfig.claude_code?.agents ?? true)
? loadUserAgents()
: {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
const rawProjectAgents = (pluginConfig.claude_code?.agents ?? true)
? loadProjectAgents()
: {};
const pluginAgents = pluginComponents.agents;
const rawPluginAgents = pluginComponents.agents;
const userAgents = Object.fromEntries(
Object.entries(rawUserAgents).map(([k, v]) => [
k,
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
])
);
const projectAgents = Object.fromEntries(
Object.entries(rawProjectAgents).map(([k, v]) => [
k,
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
])
);
const pluginAgents = Object.fromEntries(
Object.entries(rawPluginAgents).map(([k, v]) => [
k,
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
])
);
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled =
@@ -164,11 +184,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
const filteredConfigAgents = configAgent
? Object.fromEntries(
Object.entries(configAgent).filter(([key]) => {
Object.entries(configAgent)
.filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
.map(([key, value]) => [
key,
value ? migrateAgentConfig(value as Record<string, unknown>) : value,
])
)
: {};

View File

@@ -1,161 +1,158 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
createToolDenyList,
permissionValueToBoolean,
booleanToPermissionValue,
convertToolsToPermission,
convertPermissionToTools,
createAgentRestrictions,
createAgentToolRestrictions,
migrateToolsToPermission,
migratePermissionToTools,
migrateAgentConfig,
} from "./permission-compat"
import { setVersionCache, resetVersionCache } from "./opencode-version"
describe("permission-compat", () => {
describe("createToolDenyList", () => {
test("creates tools config with all values false", () => {
// #given a list of tool names
const tools = ["write", "edit", "task"]
// #when creating deny list
const result = createToolDenyList(tools)
// #then all values are false
expect(result).toEqual({ write: false, edit: false, task: false })
beforeEach(() => {
resetVersionCache()
})
test("returns empty object for empty array", () => {
// #given empty array
// #when creating deny list
const result = createToolDenyList([])
afterEach(() => {
resetVersionCache()
})
// #then returns empty object
expect(result).toEqual({})
describe("createAgentToolRestrictions", () => {
test("returns permission format for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
// #then returns permission format
expect(result).toEqual({
permission: { write: "deny", edit: "deny" },
})
})
describe("permissionValueToBoolean", () => {
test("converts allow to true", () => {
expect(permissionValueToBoolean("allow")).toBe(true)
})
test("returns tools format for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
test("converts deny to false", () => {
expect(permissionValueToBoolean("deny")).toBe(false)
})
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
test("converts ask to false", () => {
expect(permissionValueToBoolean("ask")).toBe(false)
// #then returns tools format
expect(result).toEqual({
tools: { write: false, edit: false },
})
})
describe("booleanToPermissionValue", () => {
test("converts true to allow", () => {
expect(booleanToPermissionValue(true)).toBe("allow")
})
test("assumes new format when version unknown", () => {
// #given version is null
setVersionCache(null)
test("converts false to deny", () => {
expect(booleanToPermissionValue(false)).toBe("deny")
// #when creating restrictions
const result = createAgentToolRestrictions(["write"])
// #then returns permission format (assumes new version)
expect(result).toEqual({
permission: { write: "deny" },
})
})
})
describe("convertToolsToPermission", () => {
test("converts boolean tools config to permission format", () => {
// #given tools config with booleans
describe("migrateToolsToPermission", () => {
test("converts boolean tools to permission values", () => {
// #given tools config
const tools = { write: false, edit: true, bash: false }
// #when converting to permission
const result = convertToolsToPermission(tools)
// #when migrating
const result = migrateToolsToPermission(tools)
// #then converts to permission values
expect(result).toEqual({ write: "deny", edit: "allow", bash: "deny" })
// #then converts correctly
expect(result).toEqual({
write: "deny",
edit: "allow",
bash: "deny",
})
test("handles empty tools config", () => {
// #given empty config
// #when converting
const result = convertToolsToPermission({})
// #then returns empty object
expect(result).toEqual({})
})
})
describe("convertPermissionToTools", () => {
test("converts permission to boolean tools config", () => {
describe("migratePermissionToTools", () => {
test("converts permission to boolean tools", () => {
// #given permission config
const permission = { write: "deny" as const, edit: "allow" as const }
// #when converting to tools
const result = convertPermissionToTools(permission)
// #when migrating
const result = migratePermissionToTools(permission)
// #then converts to boolean values
// #then converts correctly
expect(result).toEqual({ write: false, edit: true })
})
test("excludes ask values", () => {
// #given permission with ask value
// #given permission with ask
const permission = {
write: "deny" as const,
edit: "ask" as const,
bash: "allow" as const,
}
// #when converting
const result = convertPermissionToTools(permission)
// #when migrating
const result = migratePermissionToTools(permission)
// #then ask is excluded
expect(result).toEqual({ write: false, bash: true })
})
})
describe("createAgentRestrictions", () => {
test("creates restrictions with denied tools", () => {
// #given deny tools list
const config = { denyTools: ["write", "task"] }
// #when creating restrictions
const result = createAgentRestrictions(config)
// #then creates tools config
expect(result).toEqual({ tools: { write: false, task: false } })
})
test("creates restrictions with permission", () => {
// #given permission config
describe("migrateAgentConfig", () => {
test("migrates tools to permission for v1.1.1+", () => {
// #given v1.1.1 and config with tools
setVersionCache("1.1.1")
const config = {
permission: { edit: "deny" as const, bash: "ask" as const },
model: "test",
tools: { write: false, edit: false },
}
// #when creating restrictions
const result = createAgentRestrictions(config)
// #when migrating
const result = migrateAgentConfig(config)
// #then creates permission config
expect(result).toEqual({
permission: { edit: "deny", bash: "ask" },
})
// #then converts to permission
expect(result.tools).toBeUndefined()
expect(result.permission).toEqual({ write: "deny", edit: "deny" })
expect(result.model).toBe("test")
})
test("combines tools and permission", () => {
// #given both deny tools and permission
test("migrates permission to tools for old versions", () => {
// #given old version and config with permission
setVersionCache("1.0.150")
const config = {
denyTools: ["task"],
permission: { edit: "deny" as const },
model: "test",
permission: { write: "deny" as const, edit: "deny" as const },
}
// #when creating restrictions
const result = createAgentRestrictions(config)
// #when migrating
const result = migrateAgentConfig(config)
// #then includes both
expect(result).toEqual({
tools: { task: false },
permission: { edit: "deny" },
})
// #then converts to tools
expect(result.permission).toBeUndefined()
expect(result.tools).toEqual({ write: false, edit: false })
})
test("returns empty object when no config provided", () => {
// #given empty config
// #when creating restrictions
const result = createAgentRestrictions({})
test("preserves other config fields", () => {
// #given config with other fields
setVersionCache("1.1.1")
const config = {
model: "test",
temperature: 0.5,
prompt: "hello",
tools: { write: false },
}
// #then returns empty object
expect(result).toEqual({})
// #when migrating
const result = migrateAgentConfig(config)
// #then preserves other fields
expect(result.model).toBe("test")
expect(result.temperature).toBe(0.5)
expect(result.prompt).toBe("hello")
})
})
})

View File

@@ -1,72 +1,78 @@
import { supportsNewPermissionSystem as checkNewPermissionSystem } from "./opencode-version"
import { supportsNewPermissionSystem } from "./opencode-version"
export type PermissionValue = "ask" | "allow" | "deny"
export type BashPermission = PermissionValue | Record<string, PermissionValue>
export interface StandardPermission {
edit?: PermissionValue
bash?: BashPermission
webfetch?: PermissionValue
doom_loop?: PermissionValue
external_directory?: PermissionValue
export interface LegacyToolsFormat {
tools: Record<string, boolean>
}
export interface ToolsConfig {
[toolName: string]: boolean
export interface NewPermissionFormat {
permission: Record<string, PermissionValue>
}
export interface AgentPermissionConfig {
permission?: StandardPermission
tools?: ToolsConfig
export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat
export function createAgentToolRestrictions(
denyTools: string[]
): VersionAwareRestrictions {
if (supportsNewPermissionSystem()) {
return {
permission: Object.fromEntries(
denyTools.map((tool) => [tool, "deny" as const])
),
}
}
export { checkNewPermissionSystem as supportsNewPermissionSystemFromCompat }
export function createToolDenyList(toolNames: string[]): ToolsConfig {
return Object.fromEntries(toolNames.map((name) => [name, false]))
return {
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
}
}
export function permissionValueToBoolean(value: PermissionValue): boolean {
return value === "allow"
}
export function booleanToPermissionValue(value: boolean): PermissionValue {
return value ? "allow" : "deny"
}
export function convertToolsToPermission(
tools: ToolsConfig
export function migrateToolsToPermission(
tools: Record<string, boolean>
): Record<string, PermissionValue> {
return Object.fromEntries(
Object.entries(tools).map(([key, value]) => [
key,
booleanToPermissionValue(value),
value ? ("allow" as const) : ("deny" as const),
])
)
}
export function convertPermissionToTools(
export function migratePermissionToTools(
permission: Record<string, PermissionValue>
): ToolsConfig {
): Record<string, boolean> {
return Object.fromEntries(
Object.entries(permission)
.filter(([, value]) => value !== "ask")
.map(([key, value]) => [key, permissionValueToBoolean(value)])
.map(([key, value]) => [key, value === "allow"])
)
}
export function createAgentRestrictions(config: {
denyTools?: string[]
permission?: StandardPermission
}): AgentPermissionConfig {
const result: AgentPermissionConfig = {}
export function migrateAgentConfig(
config: Record<string, unknown>
): Record<string, unknown> {
const result = { ...config }
if (config.denyTools && config.denyTools.length > 0) {
result.tools = createToolDenyList(config.denyTools)
if (supportsNewPermissionSystem()) {
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
} else {
if (result.permission && typeof result.permission === "object") {
const existingTools = (result.tools as Record<string, boolean>) || {}
const migratedTools = migratePermissionToTools(
result.permission as Record<string, PermissionValue>
)
result.tools = { ...migratedTools, ...existingTools }
delete result.permission
}
if (config.permission) {
result.permission = config.permission
}
return result