diff --git a/src/agents/document-writer.ts b/src/agents/document-writer.ts index 5fc9f79..753173b 100644 --- a/src/agents/document-writer.ts +++ b/src/agents/document-writer.ts @@ -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: ` 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. diff --git a/src/agents/explore.ts b/src/agents/explore.ts index 4d04c0f..4f8cd12 100644 --- a/src/agents/explore.ts +++ b/src/agents/explore.ts @@ -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 diff --git a/src/agents/oracle.ts b/src/agents/oracle.ts index c240250..26b8652 100644 --- a/src/agents/oracle.ts +++ b/src/agents/oracle.ts @@ -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, } diff --git a/src/shared/index.ts b/src/shared/index.ts index 2eef06b..3c3f25e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/opencode-version.test.ts b/src/shared/opencode-version.test.ts new file mode 100644 index 0000000..9f7c1f7 --- /dev/null +++ b/src/shared/opencode-version.test.ts @@ -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") + }) + }) +}) diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts new file mode 100644 index 0000000..c70069a --- /dev/null +++ b/src/shared/opencode-version.ts @@ -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 +} diff --git a/src/shared/permission-compat.test.ts b/src/shared/permission-compat.test.ts new file mode 100644 index 0000000..057da4c --- /dev/null +++ b/src/shared/permission-compat.test.ts @@ -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({}) + }) + }) +}) diff --git a/src/shared/permission-compat.ts b/src/shared/permission-compat.ts new file mode 100644 index 0000000..1fb2255 --- /dev/null +++ b/src/shared/permission-compat.ts @@ -0,0 +1,73 @@ +import { supportsNewPermissionSystem as checkNewPermissionSystem } from "./opencode-version" + +export type PermissionValue = "ask" | "allow" | "deny" +export type BashPermission = PermissionValue | Record + +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 { + return Object.fromEntries( + Object.entries(tools).map(([key, value]) => [ + key, + booleanToPermissionValue(value), + ]) + ) +} + +export function convertPermissionToTools( + permission: Record +): 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 +}