e5d3fe96c4
Blocking fixes: - B1: Return empty restrictions for unknown/custom agents instead of EXPLORATION_AGENT_DENYLIST, allowing custom agents full tool access - B2: Use Object.create(null) consistently across all 5 agent-loading result objects to prevent prototype pollution - B3: Add code comment documenting custom agent bash access trust model - B4: Mock getOpenCodeConfigDir in opencode-config-agents-reader tests to prevent global config dir leakage Non-blocking fixes: - N1: Use resolveAgentDefinitionPaths with project boundary enforcement in opencode-config-agents-reader for path containment - N2: Add session-scoped 30s TTL cache to resolveCallableAgents to avoid redundant SDK IPC calls per tool invocation - N3: Extract shared parseToolsConfig into src/shared/parse-tools-config.ts replacing 4 duplicated local implementations - N4: Add .min(1) to AgentDefinitionPathSchema rejecting empty paths - N5: Add resolve-agent-definition-paths.test.ts covering tilde expansion, relative paths, boundary enforcement, and null containmentDir - N6: Validate agent mode against allowed values instead of bare type assertion in opencode-config-agents-reader
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
import * as fs from "node:fs"
|
|
import * as path from "node:path"
|
|
|
|
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
|
import { parseJsoncSafe } from "../../shared/jsonc-parser"
|
|
import { parseToolsConfig } from "../../shared/parse-tools-config"
|
|
import { resolveAgentDefinitionPaths } from "../../shared/resolve-agent-definition-paths"
|
|
import { loadAgentDefinitions } from "./agent-definitions-loader"
|
|
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
|
import type { ClaudeCodeAgentConfig } from "./types"
|
|
|
|
interface OpencodeConfigWithAgents {
|
|
agents?: Record<string, unknown>
|
|
agent?: Record<string, unknown>
|
|
agent_definitions?: string | string[]
|
|
}
|
|
|
|
function getConfigPaths(directory: string): string[] {
|
|
const globalConfigDir = getOpenCodeConfigDir({ binary: "opencode" })
|
|
const paths = [
|
|
path.join(directory, ".opencode", "opencode.json"),
|
|
path.join(directory, ".opencode", "opencode.jsonc"),
|
|
path.join(globalConfigDir, "opencode.json"),
|
|
path.join(globalConfigDir, "opencode.jsonc"),
|
|
]
|
|
|
|
return paths
|
|
}
|
|
|
|
function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null {
|
|
if (!agentData || typeof agentData !== "object") {
|
|
return null
|
|
}
|
|
|
|
const agent = agentData as Record<string, unknown>
|
|
|
|
const description = agent.description ? `(opencode-config) ${String(agent.description)}` : "(opencode-config) "
|
|
|
|
const mappedModel = mapClaudeModelToOpenCode(
|
|
agent.model ? String(agent.model) : undefined
|
|
)
|
|
const modelString = mappedModel
|
|
? `${mappedModel.providerID}/${mappedModel.modelID}`
|
|
: undefined
|
|
|
|
const VALID_MODES = ["subagent", "primary", "all"] as const
|
|
const rawMode = typeof agent.mode === "string" ? agent.mode : undefined
|
|
const mode = rawMode && (VALID_MODES as readonly string[]).includes(rawMode)
|
|
? (rawMode as "subagent" | "primary" | "all")
|
|
: "subagent"
|
|
|
|
const config: ClaudeCodeAgentConfig = {
|
|
description,
|
|
mode,
|
|
prompt: agent.prompt ? String(agent.prompt) : "",
|
|
...(modelString ? { model: modelString } : {}),
|
|
}
|
|
|
|
const toolsConfig = parseToolsConfig(agent.tools)
|
|
if (toolsConfig) {
|
|
config.tools = toolsConfig
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
export function readOpencodeConfigAgents(directory: string): Record<string, ClaudeCodeAgentConfig> {
|
|
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
|
|
|
for (const configPath of getConfigPaths(directory)) {
|
|
try {
|
|
if (!fs.existsSync(configPath)) continue
|
|
|
|
const content = fs.readFileSync(configPath, "utf-8")
|
|
const parseResult = parseJsoncSafe<OpencodeConfigWithAgents>(content)
|
|
|
|
if (!parseResult.data) continue
|
|
|
|
const configDir = path.dirname(configPath)
|
|
|
|
const agentsToLoad = parseResult.data.agents || parseResult.data.agent
|
|
|
|
if (agentsToLoad && typeof agentsToLoad === "object") {
|
|
for (const [agentName, agentData] of Object.entries(agentsToLoad)) {
|
|
if (Object.hasOwn(result, agentName)) continue
|
|
const converted = convertInlineAgent(agentData)
|
|
if (converted) {
|
|
result[agentName] = converted
|
|
}
|
|
}
|
|
}
|
|
|
|
if (parseResult.data.agent_definitions) {
|
|
const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions)
|
|
const resolvedPaths = resolveAgentDefinitionPaths(definitionPaths, configDir, directory)
|
|
|
|
const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config")
|
|
|
|
for (const [name, config] of Object.entries(definitionAgents)) {
|
|
if (!Object.hasOwn(result, name)) {
|
|
result[name] = config
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function extractDefinitionPaths(definitionPaths: unknown): string[] {
|
|
if (typeof definitionPaths === "string") {
|
|
return [definitionPaths]
|
|
}
|
|
|
|
if (Array.isArray(definitionPaths)) {
|
|
return definitionPaths
|
|
.filter((p) => typeof p === "string")
|
|
.map((p) => p as string)
|
|
}
|
|
|
|
return []
|
|
}
|