fix(skills): register security skills at runtime
This commit is contained in:
@@ -54,6 +54,8 @@
|
||||
"git-master",
|
||||
"review-work",
|
||||
"ai-slop-remover",
|
||||
"security-research",
|
||||
"security-review",
|
||||
"team-mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { describe, expect, test } from "bun:test"
|
||||
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema disabled_skills", () => {
|
||||
test("accepts review-work and ai-slop-remover", () => {
|
||||
test("accepts review-work, ai-slop-remover, and runtime security skills", () => {
|
||||
// given
|
||||
const config = {
|
||||
disabled_skills: ["review-work", "ai-slop-remover"],
|
||||
disabled_skills: [
|
||||
"review-work",
|
||||
"ai-slop-remover",
|
||||
"security-research",
|
||||
"security-review",
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -17,6 +22,8 @@ describe("OhMyOpenCodeConfigSchema disabled_skills", () => {
|
||||
expect(result.data.disabled_skills).toEqual([
|
||||
"review-work",
|
||||
"ai-slop-remover",
|
||||
"security-research",
|
||||
"security-review",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ export const BuiltinSkillNameSchema = z.enum([
|
||||
"git-master",
|
||||
"review-work",
|
||||
"ai-slop-remover",
|
||||
"security-research",
|
||||
"security-review",
|
||||
"team-mode",
|
||||
])
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ export function createManagers(args: {
|
||||
tmuxConfig: TmuxConfig
|
||||
modelCacheState: ModelCacheState
|
||||
backgroundNotificationHookEnabled: boolean
|
||||
runtimeSkillSourceUrl?: string
|
||||
deps?: Partial<CreateManagersDeps>
|
||||
}): Managers {
|
||||
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args
|
||||
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled, runtimeSkillSourceUrl } = args
|
||||
const deps = { ...defaultCreateManagersDeps, ...args.deps }
|
||||
|
||||
// Only mark the server as in-process when the SDK actually exposes a
|
||||
@@ -151,6 +152,7 @@ export function createManagers(args: {
|
||||
ctx: { directory: ctx.directory, client: ctx.client },
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
runtimeSkillSourceUrl,
|
||||
})
|
||||
return {
|
||||
tmuxSessionManager,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,93 @@ describe("MCP env allowlist initialization", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("runtime security skill source registration", () => {
|
||||
test("adds the runtime skill source URL to the live OpenCode config", async () => {
|
||||
// given
|
||||
const pluginConfig = createPluginConfig({})
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
agent: {},
|
||||
skills: {
|
||||
urls: ["https://example.com/skills"],
|
||||
paths: ["/tmp/user-skills"],
|
||||
},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||
})
|
||||
|
||||
// when
|
||||
await handler(config)
|
||||
|
||||
// then
|
||||
expect(config.skills).toMatchObject({
|
||||
urls: ["https://example.com/skills", "http://127.0.0.1:49152/"],
|
||||
paths: ["/tmp/user-skills"],
|
||||
})
|
||||
})
|
||||
|
||||
test("adds the runtime skill source when only security-review remains enabled", async () => {
|
||||
// given
|
||||
const pluginConfig = createPluginConfig({
|
||||
disabled_skills: ["security-research"],
|
||||
})
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||
})
|
||||
|
||||
// when
|
||||
await handler(config)
|
||||
|
||||
// then
|
||||
expect(config.skills).toMatchObject({
|
||||
urls: ["http://127.0.0.1:49152/"],
|
||||
})
|
||||
})
|
||||
|
||||
test("does not add a runtime skill source when both security skills are disabled", async () => {
|
||||
// given
|
||||
const pluginConfig = createPluginConfig({
|
||||
disabled_skills: ["security-research", "security-review"],
|
||||
})
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||
})
|
||||
|
||||
// when
|
||||
await handler(config)
|
||||
|
||||
// then
|
||||
expect(config.skills).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Plan agent demote behavior", () => {
|
||||
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
|
||||
// #given
|
||||
@@ -1002,10 +1089,11 @@ describe("Prometheus direct override priority over category", () => {
|
||||
// #then - prompt_append is appended to base prompt, not overwriting it
|
||||
const agents = config.agent as Record<string, { prompt?: string }>
|
||||
const pKey = getAgentListDisplayName("prometheus")
|
||||
const prometheusPrompt = agents[pKey]?.prompt
|
||||
expect(agents[pKey]).toBeDefined()
|
||||
expect(agents[pKey].prompt).toContain("Prometheus")
|
||||
expect(agents[pKey].prompt).toContain(customInstructions)
|
||||
expect(agents[pKey].prompt!.endsWith(customInstructions)).toBe(true)
|
||||
expect(prometheusPrompt).toContain("Prometheus")
|
||||
expect(prometheusPrompt).toContain(customInstructions)
|
||||
expect(prometheusPrompt?.endsWith(customInstructions)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { applyRuntimeSkillSourceConfig } from "../features/opencode-runtime-skills"
|
||||
import { setAdditionalAllowedMcpEnvVars } from "../features/claude-code-mcp-loader";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
import { log } from "../shared";
|
||||
@@ -26,13 +27,14 @@ function collectTrustedVisionCapableModels(
|
||||
}
|
||||
|
||||
export interface ConfigHandlerDeps {
|
||||
ctx: { directory: string; client?: any };
|
||||
ctx: { directory: string; client?: unknown };
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
modelCacheState: ModelCacheState;
|
||||
runtimeSkillSourceUrl?: string;
|
||||
}
|
||||
|
||||
export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const { ctx, pluginConfig, modelCacheState } = deps;
|
||||
const { ctx, pluginConfig, modelCacheState, runtimeSkillSourceUrl } = deps;
|
||||
|
||||
return async (config: Record<string, unknown>) => {
|
||||
const formatterConfig = config.formatter;
|
||||
@@ -59,6 +61,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
applyToolConfig({ config, pluginConfig, agentResult });
|
||||
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||
if (runtimeSkillSourceUrl) {
|
||||
applyRuntimeSkillSourceConfig({
|
||||
config,
|
||||
pluginConfig,
|
||||
sourceUrl: runtimeSkillSourceUrl,
|
||||
})
|
||||
}
|
||||
|
||||
config.formatter = formatterConfig;
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ const mockCreateManagers = mock(() => ({
|
||||
skillMcpManager: { disconnectAll: async () => {} },
|
||||
configHandler: async () => {},
|
||||
}))
|
||||
const mockRuntimeSkillSourceStop = mock(() => {})
|
||||
const mockCreateRuntimeSkillSourceServer = mock(
|
||||
(options: { readonly skills: readonly { readonly name: string }[] }) => ({
|
||||
url: `http://127.0.0.1:49152/${options.skills.map((skill) => skill.name).join(",")}`,
|
||||
stop: mockRuntimeSkillSourceStop,
|
||||
}),
|
||||
)
|
||||
const mockCreateTools = mock(async () => ({
|
||||
mergedSkills: [],
|
||||
availableSkills: [],
|
||||
@@ -71,6 +78,7 @@ function createTestPluginModule(): ReturnType<typeof createPluginModule> {
|
||||
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never,
|
||||
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never,
|
||||
createManagers: mockCreateManagers as never,
|
||||
createRuntimeSkillSourceServer: mockCreateRuntimeSkillSourceServer as never,
|
||||
createTools: mockCreateTools as never,
|
||||
createHooks: mockCreateHooks as never,
|
||||
createPluginInterface: mockCreatePluginInterface as never,
|
||||
@@ -91,6 +99,8 @@ describe("createPluginModule()", () => {
|
||||
mockInjectServerAuthIntoClient.mockClear()
|
||||
mockLoadPluginConfig.mockClear()
|
||||
mockCreateManagers.mockClear()
|
||||
mockRuntimeSkillSourceStop.mockClear()
|
||||
mockCreateRuntimeSkillSourceServer.mockClear()
|
||||
mockCreateTools.mockClear()
|
||||
mockCreateHooks.mockClear()
|
||||
mockCreatePluginInterface.mockClear()
|
||||
@@ -123,6 +133,71 @@ describe("createPluginModule()", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given bundled security skills are enabled", () => {
|
||||
it("#then startup exposes them through a runtime skill source URL", async () => {
|
||||
// given
|
||||
const pluginModule = createTestPluginModule()
|
||||
mockLoadPluginConfig.mockReturnValue({})
|
||||
|
||||
// when
|
||||
await pluginModule.server({
|
||||
directory: "/tmp/project",
|
||||
client: {},
|
||||
} as Parameters<typeof pluginModule.server>[0])
|
||||
|
||||
// then
|
||||
const sourceArgs = mockCreateRuntimeSkillSourceServer.mock.calls.at(0)?.[0]
|
||||
expect(sourceArgs?.skills.map((skill) => skill.name)).toEqual([
|
||||
"security-research",
|
||||
"security-review",
|
||||
])
|
||||
expect(mockCreateManagers.mock.calls.at(0)?.[0]).toMatchObject({
|
||||
runtimeSkillSourceUrl: "http://127.0.0.1:49152/security-research,security-review",
|
||||
})
|
||||
})
|
||||
|
||||
it("#then dispose stops the runtime skill source", async () => {
|
||||
// given
|
||||
const pluginModule = createTestPluginModule()
|
||||
mockLoadPluginConfig.mockReturnValue({})
|
||||
|
||||
// when
|
||||
const hooks: Awaited<ReturnType<typeof pluginModule.server>> & {
|
||||
dispose?: () => Promise<void>
|
||||
} = await pluginModule.server({
|
||||
directory: "/tmp/project",
|
||||
client: {},
|
||||
} as Parameters<typeof pluginModule.server>[0])
|
||||
await hooks.dispose?.()
|
||||
|
||||
// then
|
||||
expect(mockRuntimeSkillSourceStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given security-research is disabled", () => {
|
||||
it("#then startup still exposes security-review through the runtime skill source", async () => {
|
||||
// given
|
||||
const pluginModule = createTestPluginModule()
|
||||
mockLoadPluginConfig.mockReturnValue({
|
||||
disabled_skills: ["security-research"],
|
||||
})
|
||||
|
||||
// when
|
||||
await pluginModule.server({
|
||||
directory: "/tmp/project",
|
||||
client: {},
|
||||
} as Parameters<typeof pluginModule.server>[0])
|
||||
|
||||
// then
|
||||
const sourceArgs = mockCreateRuntimeSkillSourceServer.mock.calls.at(0)?.[0]
|
||||
expect(sourceArgs?.skills.map((skill) => skill.name)).toEqual(["security-review"])
|
||||
expect(mockCreateManagers.mock.calls.at(0)?.[0]).toMatchObject({
|
||||
runtimeSkillSourceUrl: "http://127.0.0.1:49152/security-review",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given duplicate OMO plugin entries are configured", () => {
|
||||
it("#then startup warns and returns no prompt-producing hooks", async () => {
|
||||
// given
|
||||
|
||||
@@ -6,7 +6,9 @@ import { createHooks } from "../create-hooks"
|
||||
import { createManagers } from "../create-managers"
|
||||
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "../create-runtime-tmux-config"
|
||||
import { createTools } from "../create-tools"
|
||||
import { createRuntimeSkillSourceServer, selectRuntimeSecuritySkills } from "../features/opencode-runtime-skills"
|
||||
import { initializeOpenClaw } from "../openclaw"
|
||||
import { createPluginDispose } from "../plugin-dispose"
|
||||
import { createPluginInterface } from "../plugin-interface"
|
||||
import { loadPluginConfig } from "../plugin-config"
|
||||
import { createModelCacheState } from "../plugin-state"
|
||||
@@ -30,8 +32,9 @@ import { migrateLegacyWorkspaceDirectory } from "../shared/legacy-workspace-migr
|
||||
import { injectServerAuthIntoClient } from "../shared/opencode-server-auth"
|
||||
import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash"
|
||||
|
||||
type HooksWithCompactionAutocontinue = Hooks & {
|
||||
type HooksWithRuntimeLifecycle = Hooks & {
|
||||
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
||||
dispose?: () => Promise<void>
|
||||
}
|
||||
|
||||
export type PluginModuleDeps = {
|
||||
@@ -56,6 +59,7 @@ export type PluginModuleDeps = {
|
||||
createModelCacheState: typeof createModelCacheState
|
||||
createManagers: typeof createManagers
|
||||
createTools: typeof createTools
|
||||
createRuntimeSkillSourceServer: typeof createRuntimeSkillSourceServer
|
||||
createHooks: typeof createHooks
|
||||
createPluginInterface: typeof createPluginInterface
|
||||
}
|
||||
@@ -82,6 +86,7 @@ const defaultPluginModuleDeps: PluginModuleDeps = {
|
||||
createModelCacheState,
|
||||
createManagers,
|
||||
createTools,
|
||||
createRuntimeSkillSourceServer,
|
||||
createHooks,
|
||||
createPluginInterface,
|
||||
}
|
||||
@@ -111,6 +116,11 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
||||
deps.injectServerAuthIntoClient(input.client)
|
||||
|
||||
const pluginConfig = deps.loadPluginConfig(input.directory, input)
|
||||
const runtimeSecuritySkills = selectRuntimeSecuritySkills(pluginConfig)
|
||||
const runtimeSkillSource =
|
||||
runtimeSecuritySkills.length > 0
|
||||
? deps.createRuntimeSkillSourceServer({ skills: runtimeSecuritySkills })
|
||||
: undefined
|
||||
deps.initI18n(pluginConfig.i18n?.locale ? { locale: pluginConfig.i18n.locale } : undefined)
|
||||
deps.setAgentSortOrder(pluginConfig.agent_order)
|
||||
|
||||
@@ -129,8 +139,12 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
||||
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[team-mode] init failed:", err)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.warn("[team-mode] init failed:", error)
|
||||
} else {
|
||||
console.warn("[team-mode] init failed:", String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
|
||||
@@ -154,6 +168,7 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
||||
tmuxConfig,
|
||||
modelCacheState,
|
||||
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
||||
runtimeSkillSourceUrl: runtimeSkillSource?.url,
|
||||
})
|
||||
|
||||
const toolsResult = await deps.createTools({
|
||||
@@ -183,12 +198,23 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
||||
tools: toolsResult.filteredTools,
|
||||
})
|
||||
|
||||
const pluginHooks: HooksWithCompactionAutocontinue = {
|
||||
const dispose = createPluginDispose({
|
||||
backgroundManager: managers.backgroundManager,
|
||||
skillMcpManager: managers.skillMcpManager,
|
||||
disposeHooks: hooks.disposeHooks,
|
||||
})
|
||||
|
||||
const pluginHooks: HooksWithRuntimeLifecycle = {
|
||||
...pluginInterface,
|
||||
|
||||
"experimental.session.compacting": createSessionCompactingHandler(hooks),
|
||||
|
||||
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
|
||||
|
||||
dispose: async (): Promise<void> => {
|
||||
runtimeSkillSource?.stop()
|
||||
await dispose()
|
||||
},
|
||||
}
|
||||
|
||||
return pluginHooks
|
||||
|
||||
Reference in New Issue
Block a user