fix(skills): register security skills at runtime

This commit is contained in:
YeonGyu-Kim
2026-05-30 20:38:59 +09:00
parent d069f6e3d3
commit f9172c6c28
14 changed files with 537 additions and 12 deletions
@@ -0,0 +1,7 @@
export {
applyRuntimeSkillSourceConfig,
selectRuntimeSecuritySkills,
type OpenCodeSkillHostConfig,
type RuntimeSkillSourceEntry,
} from "./runtime-skill-config"
export { createRuntimeSkillSourceServer, type RuntimeSkillSourceServer } from "./source-server"
@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import {
applyRuntimeSkillSourceConfig,
selectRuntimeSecuritySkills,
type OpenCodeSkillHostConfig,
} from "./runtime-skill-config"
type DisabledSkillName = NonNullable<OhMyOpenCodeConfig["disabled_skills"]>[number]
function createPluginConfig(disabledSkills?: readonly DisabledSkillName[]): OhMyOpenCodeConfig {
return {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
disabled_skills: disabledSkills ? [...disabledSkills] : undefined,
}
}
describe("OpenCode runtime skill source config", () => {
test("adds the runtime source URL while preserving existing skill URLs and paths", () => {
// given
const config: OpenCodeSkillHostConfig = {
skills: {
urls: ["https://example.com/skills"],
paths: ["/keep/user/path"],
},
}
// when
applyRuntimeSkillSourceConfig({
config,
pluginConfig: createPluginConfig(),
sourceUrl: "http://127.0.0.1:49152/",
})
// then
expect(config.skills?.urls).toEqual([
"https://example.com/skills",
"http://127.0.0.1:49152/",
])
expect(config.skills?.paths).toEqual(["/keep/user/path"])
})
test("deduplicates the runtime source URL", () => {
// given
const config: OpenCodeSkillHostConfig = {
skills: {
urls: ["http://127.0.0.1:49152/"],
},
}
// when
applyRuntimeSkillSourceConfig({
config,
pluginConfig: createPluginConfig(),
sourceUrl: "http://127.0.0.1:49152/",
})
// then
expect(config.skills?.urls).toEqual(["http://127.0.0.1:49152/"])
})
test("does not create skills config when every runtime security skill is disabled", () => {
// given
const config: OpenCodeSkillHostConfig = {}
// when
applyRuntimeSkillSourceConfig({
config,
pluginConfig: createPluginConfig(["security-research", "security-review"]),
sourceUrl: "http://127.0.0.1:49152/",
})
// then
expect(config.skills).toBeUndefined()
})
test("security-research disablement keeps security-review enabled", () => {
// given
const pluginConfig = createPluginConfig(["security-research"])
// when
const skills = selectRuntimeSecuritySkills(pluginConfig)
// then
expect(skills.map((skill) => skill.name)).toEqual(["security-review"])
})
test("security-review disablement suppresses only the review alias", () => {
// given
const pluginConfig = createPluginConfig(["security-review"])
// when
const skills = selectRuntimeSecuritySkills(pluginConfig)
// then
expect(skills.map((skill) => skill.name)).toEqual(["security-research"])
})
})
@@ -0,0 +1,65 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { securityResearchSkill, securityReviewSkill } from "../builtin-skills/skills/index"
import { createOpenCodeSkillMarkdown, type OpenCodeSkillMarkdown } from "./skill-markdown"
export type RuntimeSkillSourceEntry = OpenCodeSkillMarkdown
export type OpenCodeSkillsHostConfig = {
readonly paths?: readonly string[]
readonly urls?: readonly string[]
readonly [key: string]: unknown
}
export type OpenCodeSkillHostConfig = Record<string, unknown> & {
skills?: OpenCodeSkillsHostConfig
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function appendUnique(values: readonly string[], next: string): string[] {
if (values.includes(next)) return [...values]
return [...values, next]
}
export function selectRuntimeSecuritySkills(
pluginConfig: Pick<OhMyOpenCodeConfig, "disabled_skills"> = {},
): RuntimeSkillSourceEntry[] {
const disabledSkills = new Set(pluginConfig.disabled_skills ?? [])
const includeResearch = !disabledSkills.has("security-research")
const includeReview = !disabledSkills.has("security-review")
if (!includeResearch && !includeReview) return []
const skills = []
if (includeResearch) {
skills.push(securityResearchSkill)
}
if (includeReview) {
skills.push(securityReviewSkill)
}
return skills.map((skill) => createOpenCodeSkillMarkdown(skill))
}
export function applyRuntimeSkillSourceConfig(params: {
readonly config: OpenCodeSkillHostConfig
readonly pluginConfig: Pick<OhMyOpenCodeConfig, "disabled_skills">
readonly sourceUrl: string
}): void {
if (selectRuntimeSecuritySkills(params.pluginConfig).length === 0) return
const existingSkills = isRecord(params.config.skills) ? params.config.skills : {}
const existingUrls = toStringList(existingSkills.urls)
const nextUrls = appendUnique(existingUrls, params.sourceUrl)
params.config.skills = {
...existingSkills,
urls: nextUrls,
}
}
@@ -0,0 +1,25 @@
import type { BuiltinSkill } from "../builtin-skills/types"
export type OpenCodeSkillMarkdown = {
readonly name: string
readonly description: string
readonly markdown: string
}
export function createOpenCodeSkillMarkdown(skill: BuiltinSkill): OpenCodeSkillMarkdown {
const body = skill.template.trimStart()
const markdown = [
"---",
`name: ${skill.name}`,
`description: ${JSON.stringify(skill.description)}`,
"---",
"",
body,
].join("\n")
return {
name: skill.name,
description: skill.description,
markdown,
}
}
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createRuntimeSkillSourceServer } from "./source-server"
import { selectRuntimeSecuritySkills } from "./runtime-skill-config"
let cleanupServer: { readonly stop: () => void } | undefined
afterEach(() => {
cleanupServer?.stop()
cleanupServer = undefined
})
describe("runtime security skill source server", () => {
test("serves an OpenCode skill index and markdown files with matching frontmatter names", async () => {
// given
const source = createRuntimeSkillSourceServer({
skills: selectRuntimeSecuritySkills(),
})
cleanupServer = source
// when
const indexResponse = await fetch(new URL("index.json", source.url))
const index = await indexResponse.json()
const researchResponse = await fetch(new URL("security-research/SKILL.md", source.url))
const reviewResponse = await fetch(new URL("security-review/SKILL.md", source.url))
const researchMarkdown = await researchResponse.text()
const reviewMarkdown = await reviewResponse.text()
// then
expect(indexResponse.status).toBe(200)
expect(index).toEqual({
skills: [
{ name: "security-research", files: ["SKILL.md"] },
{ name: "security-review", files: ["SKILL.md"] },
],
})
expect(researchResponse.status).toBe(200)
expect(reviewResponse.status).toBe(200)
expect(researchMarkdown).toStartWith("---\nname: security-research\n")
expect(reviewMarkdown).toStartWith("---\nname: security-review\n")
expect(researchMarkdown).toContain("Security Research - Team Mode Vulnerability Audit")
expect(reviewMarkdown).toContain("Security Research - Team Mode Vulnerability Audit")
})
test("returns 404 for unknown paths", async () => {
// given
const source = createRuntimeSkillSourceServer({
skills: selectRuntimeSecuritySkills(),
})
cleanupServer = source
// when
const response = await fetch(new URL("missing/SKILL.md", source.url))
// then
expect(response.status).toBe(404)
})
})
@@ -0,0 +1,58 @@
import type { RuntimeSkillSourceEntry } from "./runtime-skill-config"
export type RuntimeSkillSourceServer = {
readonly url: string
readonly stop: () => void
}
function jsonResponse(body: unknown): Response {
return Response.json(body, {
headers: {
"cache-control": "no-store",
},
})
}
function markdownResponse(markdown: string): Response {
return new Response(markdown, {
headers: {
"cache-control": "no-store",
"content-type": "text/markdown; charset=utf-8",
},
})
}
export function createRuntimeSkillSourceServer(options: {
readonly skills: readonly RuntimeSkillSourceEntry[]
}): RuntimeSkillSourceServer {
const skillMarkdownByPath = new Map(
options.skills.map((skill) => [`/${skill.name}/SKILL.md`, skill.markdown]),
)
const index = {
skills: options.skills.map((skill) => ({
name: skill.name,
files: ["SKILL.md"],
})),
}
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/" || url.pathname === "/index.json") {
return jsonResponse(index)
}
const markdown = skillMarkdownByPath.get(url.pathname)
if (markdown) return markdownResponse(markdown)
return new Response("not found", { status: 404 })
},
})
return {
url: server.url.toString(),
stop: () => server.stop(true),
}
}