feat: OpenCode v1.1.1 permission system compatibility (#489)
* feat: add OpenCode v1.1.1 version detection and permission compatibility utilities - Add opencode-version.ts: Detect installed OpenCode version and support API - Add permission-compat.ts: Compatibility layer for permission system migration - Add comprehensive tests (418 lines total) - Export new utilities from shared/index.ts 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode) * fix: update agent permission configs for OpenCode v1.1.1 compatibility - Fix document-writer: change invalid 'permission: { background_task: deny }' to 'tools: { background_task: false }' - Fix explore: split 'permission' and 'tools' config, move tool-level denials to 'tools' key - Fix oracle: split 'permission' and 'tools' config, move tool-level denials to 'tools' key - Align all agents with v1.1.1 permission system structure 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -20,7 +20,7 @@ export function createDocumentWriterAgent(
|
||||
"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,
|
||||
permission: { background_task: "deny" },
|
||||
tools: { background_task: false },
|
||||
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.
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
permission: { write: "deny", edit: "deny", background_task: "deny" },
|
||||
tools: { write: false, background_task: false },
|
||||
permission: { edit: "deny" as const },
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
|
||||
## Your Mission
|
||||
|
||||
@@ -103,7 +103,8 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
permission: { write: "deny", edit: "deny", task: "deny", background_task: "deny" },
|
||||
tools: { write: false, task: false, background_task: false },
|
||||
permission: { edit: "deny" as const },
|
||||
prompt: ORACLE_SYSTEM_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,5 @@ export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
export * from "./opencode-config-dir"
|
||||
export * from "./opencode-version"
|
||||
export * from "./permission-compat"
|
||||
|
||||
257
src/shared/opencode-version.test.ts
Normal file
257
src/shared/opencode-version.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import * as childProcess from "child_process"
|
||||
import {
|
||||
parseVersion,
|
||||
compareVersions,
|
||||
isVersionGte,
|
||||
isVersionLt,
|
||||
getOpenCodeVersion,
|
||||
supportsNewPermissionSystem,
|
||||
usesLegacyToolsSystem,
|
||||
resetVersionCache,
|
||||
setVersionCache,
|
||||
PERMISSION_BREAKING_VERSION,
|
||||
} from "./opencode-version"
|
||||
|
||||
describe("opencode-version", () => {
|
||||
describe("parseVersion", () => {
|
||||
test("parses simple version", () => {
|
||||
// #given a simple version string
|
||||
const version = "1.2.3"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns array of numbers
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles v prefix", () => {
|
||||
// #given version with v prefix
|
||||
const version = "v1.2.3"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then strips prefix and parses correctly
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles prerelease suffix", () => {
|
||||
// #given version with prerelease
|
||||
const version = "1.2.3-beta.1"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then ignores prerelease part
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles two-part version", () => {
|
||||
// #given two-part version
|
||||
const version = "1.2"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns two numbers
|
||||
expect(result).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("compareVersions", () => {
|
||||
test("returns 0 for equal versions", () => {
|
||||
// #given two equal versions
|
||||
// #when compared
|
||||
const result = compareVersions("1.1.1", "1.1.1")
|
||||
|
||||
// #then returns 0
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
test("returns 1 when a > b", () => {
|
||||
// #given a is greater than b
|
||||
// #when compared
|
||||
const result = compareVersions("1.2.0", "1.1.0")
|
||||
|
||||
// #then returns 1
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
// #given a is less than b
|
||||
// #when compared
|
||||
const result = compareVersions("1.0.9", "1.1.0")
|
||||
|
||||
// #then returns -1
|
||||
expect(result).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles different length versions", () => {
|
||||
// #given versions with different lengths
|
||||
// #when compared
|
||||
expect(compareVersions("1.1", "1.1.0")).toBe(0)
|
||||
expect(compareVersions("1.1.1", "1.1")).toBe(1)
|
||||
expect(compareVersions("1.1", "1.1.1")).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles major version differences", () => {
|
||||
// #given major version difference
|
||||
// #when compared
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1)
|
||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionGte", () => {
|
||||
test("returns true when a >= b", () => {
|
||||
expect(isVersionGte("1.1.1", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.1.2", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.2.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("2.0.0", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(isVersionGte("1.1.0", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("1.0.9", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("0.9.9", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionLt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(isVersionLt("1.1.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionLt("1.0.150", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a >= b", () => {
|
||||
expect(isVersionLt("1.1.1", "1.1.1")).toBe(false)
|
||||
expect(isVersionLt("1.1.2", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeVersion", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns cached version on subsequent calls", () => {
|
||||
// #given version is set in cache
|
||||
setVersionCache("1.2.3")
|
||||
|
||||
// #when getting version
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns cached value
|
||||
expect(result).toBe("1.2.3")
|
||||
})
|
||||
|
||||
test("returns null when cache is set to null", () => {
|
||||
// #given cache is explicitly set to null
|
||||
setVersionCache(null)
|
||||
|
||||
// #when getting version (cache is already set)
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns null without executing command
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("supportsNewPermissionSystem", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns true for v1.1.1", () => {
|
||||
// #given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when checking permission system support
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for versions above 1.1.1", () => {
|
||||
// #given version is above 1.1.1
|
||||
setVersionCache("1.2.0")
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for versions below 1.1.1", () => {
|
||||
// #given version is below 1.1.1
|
||||
setVersionCache("1.1.0")
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when version cannot be detected", () => {
|
||||
// #given version is null (undetectable)
|
||||
setVersionCache(null)
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true (assume newer version)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("usesLegacyToolsSystem", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns true for versions below 1.1.1", () => {
|
||||
// #given version is below 1.1.1
|
||||
setVersionCache("1.0.150")
|
||||
|
||||
// #when checking
|
||||
const result = usesLegacyToolsSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for v1.1.1 and above", () => {
|
||||
// #given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when checking
|
||||
const result = usesLegacyToolsSystem()
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PERMISSION_BREAKING_VERSION", () => {
|
||||
test("is set to 1.1.1", () => {
|
||||
expect(PERMISSION_BREAKING_VERSION).toBe("1.1.1")
|
||||
})
|
||||
})
|
||||
})
|
||||
72
src/shared/opencode-version.ts
Normal file
72
src/shared/opencode-version.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export const PERMISSION_BREAKING_VERSION = "1.1.1"
|
||||
|
||||
const NOT_CACHED = Symbol("NOT_CACHED")
|
||||
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
|
||||
|
||||
export function parseVersion(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/, "").split("-")[0]
|
||||
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
||||
const partsA = parseVersion(a)
|
||||
const partsB = parseVersion(b)
|
||||
const maxLen = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const numA = partsA[i] ?? 0
|
||||
const numB = partsB[i] ?? 0
|
||||
if (numA < numB) return -1
|
||||
if (numA > numB) return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isVersionGte(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) >= 0
|
||||
}
|
||||
|
||||
export function isVersionLt(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) < 0
|
||||
}
|
||||
|
||||
export function getOpenCodeVersion(): string | null {
|
||||
if (cachedVersion !== NOT_CACHED) {
|
||||
return cachedVersion
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync("opencode --version", {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const versionMatch = result.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)
|
||||
cachedVersion = versionMatch?.[1] ?? null
|
||||
return cachedVersion
|
||||
} catch {
|
||||
cachedVersion = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsNewPermissionSystem(): boolean {
|
||||
const version = getOpenCodeVersion()
|
||||
if (!version) return true
|
||||
return isVersionGte(version, PERMISSION_BREAKING_VERSION)
|
||||
}
|
||||
|
||||
export function usesLegacyToolsSystem(): boolean {
|
||||
return !supportsNewPermissionSystem()
|
||||
}
|
||||
|
||||
export function resetVersionCache(): void {
|
||||
cachedVersion = NOT_CACHED
|
||||
}
|
||||
|
||||
export function setVersionCache(version: string | null): void {
|
||||
cachedVersion = version
|
||||
}
|
||||
161
src/shared/permission-compat.test.ts
Normal file
161
src/shared/permission-compat.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import {
|
||||
createToolDenyList,
|
||||
permissionValueToBoolean,
|
||||
booleanToPermissionValue,
|
||||
convertToolsToPermission,
|
||||
convertPermissionToTools,
|
||||
createAgentRestrictions,
|
||||
} from "./permission-compat"
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
// #given empty array
|
||||
// #when creating deny list
|
||||
const result = createToolDenyList([])
|
||||
|
||||
// #then returns empty object
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("permissionValueToBoolean", () => {
|
||||
test("converts allow to true", () => {
|
||||
expect(permissionValueToBoolean("allow")).toBe(true)
|
||||
})
|
||||
|
||||
test("converts deny to false", () => {
|
||||
expect(permissionValueToBoolean("deny")).toBe(false)
|
||||
})
|
||||
|
||||
test("converts ask to false", () => {
|
||||
expect(permissionValueToBoolean("ask")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("booleanToPermissionValue", () => {
|
||||
test("converts true to allow", () => {
|
||||
expect(booleanToPermissionValue(true)).toBe("allow")
|
||||
})
|
||||
|
||||
test("converts false to deny", () => {
|
||||
expect(booleanToPermissionValue(false)).toBe("deny")
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToolsToPermission", () => {
|
||||
test("converts boolean tools config to permission format", () => {
|
||||
// #given tools config with booleans
|
||||
const tools = { write: false, edit: true, bash: false }
|
||||
|
||||
// #when converting to permission
|
||||
const result = convertToolsToPermission(tools)
|
||||
|
||||
// #then converts to permission values
|
||||
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", () => {
|
||||
// #given permission config
|
||||
const permission = { write: "deny" as const, edit: "allow" as const }
|
||||
|
||||
// #when converting to tools
|
||||
const result = convertPermissionToTools(permission)
|
||||
|
||||
// #then converts to boolean values
|
||||
expect(result).toEqual({ write: false, edit: true })
|
||||
})
|
||||
|
||||
test("excludes ask values", () => {
|
||||
// #given permission with ask value
|
||||
const permission = {
|
||||
write: "deny" as const,
|
||||
edit: "ask" as const,
|
||||
bash: "allow" as const,
|
||||
}
|
||||
|
||||
// #when converting
|
||||
const result = convertPermissionToTools(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
|
||||
const config = {
|
||||
permission: { edit: "deny" as const, bash: "ask" as const },
|
||||
}
|
||||
|
||||
// #when creating restrictions
|
||||
const result = createAgentRestrictions(config)
|
||||
|
||||
// #then creates permission config
|
||||
expect(result).toEqual({
|
||||
permission: { edit: "deny", bash: "ask" },
|
||||
})
|
||||
})
|
||||
|
||||
test("combines tools and permission", () => {
|
||||
// #given both deny tools and permission
|
||||
const config = {
|
||||
denyTools: ["task"],
|
||||
permission: { edit: "deny" as const },
|
||||
}
|
||||
|
||||
// #when creating restrictions
|
||||
const result = createAgentRestrictions(config)
|
||||
|
||||
// #then includes both
|
||||
expect(result).toEqual({
|
||||
tools: { task: false },
|
||||
permission: { edit: "deny" },
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty object when no config provided", () => {
|
||||
// #given empty config
|
||||
// #when creating restrictions
|
||||
const result = createAgentRestrictions({})
|
||||
|
||||
// #then returns empty object
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
73
src/shared/permission-compat.ts
Normal file
73
src/shared/permission-compat.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { supportsNewPermissionSystem as checkNewPermissionSystem } 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 ToolsConfig {
|
||||
[toolName: string]: boolean
|
||||
}
|
||||
|
||||
export interface AgentPermissionConfig {
|
||||
permission?: StandardPermission
|
||||
tools?: ToolsConfig
|
||||
}
|
||||
|
||||
export { checkNewPermissionSystem as supportsNewPermissionSystemFromCompat }
|
||||
|
||||
export function createToolDenyList(toolNames: string[]): ToolsConfig {
|
||||
return Object.fromEntries(toolNames.map((name) => [name, 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
|
||||
): Record<string, PermissionValue> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([key, value]) => [
|
||||
key,
|
||||
booleanToPermissionValue(value),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function convertPermissionToTools(
|
||||
permission: Record<string, PermissionValue>
|
||||
): ToolsConfig {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission)
|
||||
.filter(([, value]) => value !== "ask")
|
||||
.map(([key, value]) => [key, permissionValueToBoolean(value)])
|
||||
)
|
||||
}
|
||||
|
||||
export function createAgentRestrictions(config: {
|
||||
denyTools?: string[]
|
||||
permission?: StandardPermission
|
||||
}): AgentPermissionConfig {
|
||||
const result: AgentPermissionConfig = {}
|
||||
|
||||
if (config.denyTools && config.denyTools.length > 0) {
|
||||
result.tools = createToolDenyList(config.denyTools)
|
||||
}
|
||||
|
||||
if (config.permission) {
|
||||
result.permission = config.permission
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user