From f9172c6c285d1d7df9f5d8dbd3562c5138bd997e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 20:38:59 +0900 Subject: [PATCH] fix(skills): register security skills at runtime --- assets/oh-my-opencode.schema.json | 2 + src/config/schema/agent-names.test.ts | 11 +- src/config/schema/agent-names.ts | 2 + src/create-managers.ts | 4 +- src/features/opencode-runtime-skills/index.ts | 7 ++ .../runtime-skill-config.test.ts | 102 ++++++++++++++++++ .../runtime-skill-config.ts | 65 +++++++++++ .../opencode-runtime-skills/skill-markdown.ts | 25 +++++ .../source-server.test.ts | 57 ++++++++++ .../opencode-runtime-skills/source-server.ts | 58 ++++++++++ src/plugin-handlers/config-handler.test.ts | 94 +++++++++++++++- src/plugin-handlers/config-handler.ts | 13 ++- src/testing/create-plugin-module.test.ts | 75 +++++++++++++ src/testing/create-plugin-module.ts | 34 +++++- 14 files changed, 537 insertions(+), 12 deletions(-) create mode 100644 src/features/opencode-runtime-skills/index.ts create mode 100644 src/features/opencode-runtime-skills/runtime-skill-config.test.ts create mode 100644 src/features/opencode-runtime-skills/runtime-skill-config.ts create mode 100644 src/features/opencode-runtime-skills/skill-markdown.ts create mode 100644 src/features/opencode-runtime-skills/source-server.test.ts create mode 100644 src/features/opencode-runtime-skills/source-server.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index f11fbffcf..7115bdfa6 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -54,6 +54,8 @@ "git-master", "review-work", "ai-slop-remover", + "security-research", + "security-review", "team-mode" ] } diff --git a/src/config/schema/agent-names.test.ts b/src/config/schema/agent-names.test.ts index d6b80a29b..9e5ae27b2 100644 --- a/src/config/schema/agent-names.test.ts +++ b/src/config/schema/agent-names.test.ts @@ -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", ]) } }) diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts index 7fefdadce..bb82491b0 100644 --- a/src/config/schema/agent-names.ts +++ b/src/config/schema/agent-names.ts @@ -22,6 +22,8 @@ export const BuiltinSkillNameSchema = z.enum([ "git-master", "review-work", "ai-slop-remover", + "security-research", + "security-review", "team-mode", ]) diff --git a/src/create-managers.ts b/src/create-managers.ts index 40b752983..090df376a 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -52,9 +52,10 @@ export function createManagers(args: { tmuxConfig: TmuxConfig modelCacheState: ModelCacheState backgroundNotificationHookEnabled: boolean + runtimeSkillSourceUrl?: string deps?: Partial }): 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, diff --git a/src/features/opencode-runtime-skills/index.ts b/src/features/opencode-runtime-skills/index.ts new file mode 100644 index 000000000..02f8d374c --- /dev/null +++ b/src/features/opencode-runtime-skills/index.ts @@ -0,0 +1,7 @@ +export { + applyRuntimeSkillSourceConfig, + selectRuntimeSecuritySkills, + type OpenCodeSkillHostConfig, + type RuntimeSkillSourceEntry, +} from "./runtime-skill-config" +export { createRuntimeSkillSourceServer, type RuntimeSkillSourceServer } from "./source-server" diff --git a/src/features/opencode-runtime-skills/runtime-skill-config.test.ts b/src/features/opencode-runtime-skills/runtime-skill-config.test.ts new file mode 100644 index 000000000..b1ca510f1 --- /dev/null +++ b/src/features/opencode-runtime-skills/runtime-skill-config.test.ts @@ -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[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"]) + }) +}) diff --git a/src/features/opencode-runtime-skills/runtime-skill-config.ts b/src/features/opencode-runtime-skills/runtime-skill-config.ts new file mode 100644 index 000000000..a0db49c6b --- /dev/null +++ b/src/features/opencode-runtime-skills/runtime-skill-config.ts @@ -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 & { + skills?: OpenCodeSkillsHostConfig +} + +function isRecord(value: unknown): value is Record { + 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 = {}, +): 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 + 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, + } +} diff --git a/src/features/opencode-runtime-skills/skill-markdown.ts b/src/features/opencode-runtime-skills/skill-markdown.ts new file mode 100644 index 000000000..dfced0427 --- /dev/null +++ b/src/features/opencode-runtime-skills/skill-markdown.ts @@ -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, + } +} diff --git a/src/features/opencode-runtime-skills/source-server.test.ts b/src/features/opencode-runtime-skills/source-server.test.ts new file mode 100644 index 000000000..6fe0d3fe8 --- /dev/null +++ b/src/features/opencode-runtime-skills/source-server.test.ts @@ -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) + }) +}) diff --git a/src/features/opencode-runtime-skills/source-server.ts b/src/features/opencode-runtime-skills/source-server.ts new file mode 100644 index 000000000..6a5ef7e4e --- /dev/null +++ b/src/features/opencode-runtime-skills/source-server.ts @@ -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), + } +} diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index bfc189341..39e785f9c 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -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 = { + 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 = { + 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 = { + 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 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) }) }) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 4ba6979f1..85ae1cf41 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -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) => { 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; diff --git a/src/testing/create-plugin-module.test.ts b/src/testing/create-plugin-module.test.ts index 8f3fa06ad..95d370aef 100644 --- a/src/testing/create-plugin-module.test.ts +++ b/src/testing/create-plugin-module.test.ts @@ -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 { 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[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> & { + dispose?: () => Promise + } = await pluginModule.server({ + directory: "/tmp/project", + client: {}, + } as Parameters[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[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 diff --git a/src/testing/create-plugin-module.ts b/src/testing/create-plugin-module.ts index 950185899..3e6455df1 100644 --- a/src/testing/create-plugin-module.ts +++ b/src/testing/create-plugin-module.ts @@ -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 } 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 = {}): 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 = {}): 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 = {}): P tmuxConfig, modelCacheState, backgroundNotificationHookEnabled: isHookEnabled("background-notification"), + runtimeSkillSourceUrl: runtimeSkillSource?.url, }) const toolsResult = await deps.createTools({ @@ -183,12 +198,23 @@ export function createPluginModule(overrides: Partial = {}): 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 => { + runtimeSkillSource?.stop() + await dispose() + }, } return pluginHooks