diff --git a/README.zh-cn.md b/README.zh-cn.md index e5eb16257..c3174e304 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -67,7 +67,7 @@ ## 评价 -> "因为它,我取消了 Cursor 的订阅。开源社区正在发生令人难以置信的事情。" - [Arthur Guiot](https://x.com/arthur_guiot/status/2008736347092382053?s=20) +> "因为它,我取消了 Cursor 的订阅。令人震撼的事在开源社区发生了。" - [Arthur Guiot](https://x.com/arthur_guiot/status/2008736347092382053?s=20) > "如果人类需要 3 个月完成的事情 Claude Code 需要 7 天,那么 Sisyphus 只需要 1 小时。它会一直工作直到任务完成。它是一个极度自律的智能体。"
- B, 量化研究员 @@ -93,7 +93,7 @@ 你同时折腾着 Claude Code、Codex、各种奇奇怪怪的开源模型。配工作流。给 Agent 调 Bug。 -这些事我们替你做完了。全部测试过。只留下真正跑得起来的。 +这些事我们替你做完了。全部测试过。只留下真正跑得起来的代码。 装上 oh-my-openagent。敲 `ultrawork`。就完事了。 @@ -109,7 +109,7 @@ Install and configure oh-my-openagent by following the instructions here: https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md ``` -或者你也可以直接去读 [安装指南](docs/guide/installation.md),但说真的,让 Agent 去干吧。人类配环境总是容易敲错字母。 +或者你也可以直接去读 [安装指南](docs/guide/installation.md),但说真的,让 Agent 去干吧。人类在配置环境的时候,总是容易敲错字母。 ### 给 LLM Agent 看的 @@ -133,6 +133,12 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head Read this and tell me why it's not just another boilerplate: https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/README.md ``` +## 路线图 + +我们正在重构代码库以支持多个代理 harness(OpenCode、Codex、Pi、Claude Code 等)。当前最紧迫的任务是包分层重构:将纯 TypeScript 核心逻辑、MCP 服务器、技能和适配器 shim 分离到不同的层,以便跨 harness 复用而不产生重复。 + +如果你有兴趣贡献,请先阅读 [ROADMAP](./ROADMAP.md)。与此重构相关的 PR 请使用 `ROADMAP` 标签,以便我们追踪。 + ## 核心亮点 @@ -352,7 +358,7 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动 - **基于哈希的编辑工具**: `LINE#ID` 引用在应用每次修改前都会验证内容。外科手术级编辑,零陈旧行错误 - **上下文注入**: 自动注入 AGENTS.md、README.md、条件规则 - **Claude Code 兼容**: 完整的 Hook 系统、命令、技能、Agents、MCP -- **内置 MCP**: websearch(Exa)、context7(文档)、grep_app(GitHub 检索) +- **内置 MCP**: websearch(Exa)、context7(文档)、grep_app(GitHub 检索) ——由插件在运行时注入,不会显示在 `opencode mcp list` 中(参见文档 [MCP docs](docs/reference/features.md#native-vs-plugin-injected-mcps)) - **会话工具**: 列出、读取、搜索、分析会话历史 - **效率功能**: Ralph Loop、Todo Enforcer、Comment Checker、Think Mode 等 - **Doctor 命令**: 内置诊断(`bunx oh-my-opencode doctor`),验证插件注册、配置、模型和环境 @@ -368,7 +374,7 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动 详细内容见 [Configuration Documentation](docs/reference/configuration.md)。 **简要概览:** -- **配置文件位置**: 兼容性层同时识别 `oh-my-openagent.json[c]` 和旧的 `oh-my-opencode.json[c]` 插件配置文件。现有安装仍大多使用旧文件名。 +- **配置文件位置**: 使用用户全局配置,同时会从当前目录向上查找 `.opencode/oh-my-openagent.json[c]`(查到 `$HOME` 为止),离当前目录越近的优先级越高。旧版 `oh-my-opencode.json[c]` 仍然有效。 - **JSONC 支持**: 支持注释和尾逗号 - **Agents**: 可对任意 Agent 覆盖模型、temperature、prompts 和权限 - **内置技能**: `playwright`(浏览器自动化)、`git-master`(原子提交) @@ -376,7 +382,7 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动 - **后台任务**: 按 provider/model 配置并发上限 - **类别**: 按领域的任务委托(`visual`、`business-logic`、自定义) - **Hooks**: 54+ 内置生命周期 Hook(启用 Team Mode 时为 61 个),都可以通过 `disabled_hooks` 控制 -- **MCPs**: 内置 websearch(Exa)、context7(文档)、grep_app(GitHub 检索) +- **MCPs**: 内置 websearch(Exa)、context7(文档)、grep_app(GitHub 检索)——由插件在运行时注入,不会显示在 `opencode mcp list` 中 - **LSP**: 包括重构工具的完整 LSP 支持 - **Experimental**: 激进截断、自动 resume 等 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/signatures/cla.json b/signatures/cla.json index 60efb7381..c30e1c301 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3567,6 +3567,14 @@ "created_at": "2026-05-29T02:24:00Z", "repoId": 1108837393, "pullRequestNo": 4594 + }, + { + "name": "fs2016l", + "id": 62631270, + "comment_id": 4578489173, + "created_at": "2026-05-29T18:20:07Z", + "repoId": 1108837393, + "pullRequestNo": 4613 } ] } \ No newline at end of file diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 8cb7d0838..5346bbb53 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -1,15 +1,207 @@ -import { readFileSync, writeFileSync } from "node:fs" +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { basename, dirname, join } from "node:path" import type { ConfigMergeResult } from "../types" import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared" import { backupConfigFile } from "./backup-config" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" import { formatErrorWithSuggestion } from "./format-error-with-suggestion" -import { detectConfigFormat } from "./opencode-config-format" +import { detectConfigFormat, type ConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" import { getPluginNameWithVersion } from "./plugin-name-with-version" import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility" +type ConfigTarget = { + readonly format: ConfigFormat + readonly path: string + readonly primary: boolean +} + +function detectConfigFormatInDir(configDir: string): { readonly format: ConfigFormat; readonly path: string } { + const configJsonc = join(configDir, "opencode.jsonc") + const configJson = join(configDir, "opencode.json") + + if (existsSync(configJsonc)) { + return { format: "jsonc", path: configJsonc } + } + if (existsSync(configJson)) { + return { format: "json", path: configJson } + } + return { format: "none", path: configJson } +} + +function getParentConfigDirForProfile(configDir: string): string | null { + const parentDir = dirname(configDir) + if (basename(parentDir) !== "profiles") return null + return dirname(parentDir) +} + +function listProfileConfigDirs(rootConfigDir: string): string[] { + const profilesDir = join(rootConfigDir, "profiles") + if (!existsSync(profilesDir)) return [] + + return readdirSync(profilesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(profilesDir, entry.name)) + .filter((profileDir) => detectConfigFormatInDir(profileDir).format !== "none") +} + +function getConfigTargets(): ConfigTarget[] { + const primaryConfigDir = getConfigDir() + const rootConfigDir = getParentConfigDirForProfile(primaryConfigDir) ?? primaryConfigDir + const targetDirs = new Set([primaryConfigDir]) + + if (rootConfigDir !== primaryConfigDir && detectConfigFormatInDir(rootConfigDir).format !== "none") { + targetDirs.add(rootConfigDir) + } + + for (const profileConfigDir of listProfileConfigDirs(rootConfigDir)) { + targetDirs.add(profileConfigDir) + } + + return Array.from(targetDirs).map((configDir) => { + const detected = detectConfigFormatInDir(configDir) + return { + ...detected, + primary: configDir === primaryConfigDir, + } + }) +} + +function isSourceOmoPluginEntry(plugin: string): boolean { + const normalized = plugin.toLowerCase().replaceAll("\\", "/") + if (!normalized.startsWith("file://")) return false + + return /\/(omo(?:-[^/]*)?|oh-my-opencode|oh-my-openagent)\/(src|dist)\/index\.(ts|js)$/.test(normalized) +} + +function isPackageOmoPluginEntry(plugin: string): boolean { + return plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) || + plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) +} + +function isOurPlugin(plugin: string): boolean { + return isPackageOmoPluginEntry(plugin) || isSourceOmoPluginEntry(plugin) +} + +function findOurPluginEntry(plugins: readonly string[]): string | undefined { + return plugins.find(isOurPlugin) +} + +function findSourcePluginEntryInTarget(target: ConfigTarget): string | null { + if (target.format === "none") return null + + const parseResult = parseOpenCodeConfigFileWithError(target.path) + const plugins = parseResult.config?.plugin ?? [] + return plugins.find(isSourceOmoPluginEntry) ?? null +} + +function choosePluginEntry(params: { + readonly existingEntry: string | undefined + readonly fallbackEntry: string + readonly preferredSourceEntry: string | null +}): string { + if (params.existingEntry && isSourceOmoPluginEntry(params.existingEntry)) { + return params.existingEntry + } + if (params.preferredSourceEntry) { + return params.preferredSourceEntry + } + return params.fallbackEntry +} + +function writePluginEntryToTarget(params: { + readonly target: ConfigTarget + readonly currentVersion: string + readonly fallbackEntry: string + readonly preferredSourceEntry: string | null +}): ConfigMergeResult { + const { target, currentVersion, fallbackEntry, preferredSourceEntry } = params + const pluginEntry = choosePluginEntry({ + existingEntry: undefined, + fallbackEntry, + preferredSourceEntry, + }) + + try { + if (target.format === "none") { + const config: OpenCodeConfig = { plugin: [pluginEntry] } + writeFileSync(target.path, JSON.stringify(config, null, 2) + "\n") + return { success: true, configPath: target.path } + } + + const parseResult = parseOpenCodeConfigFileWithError(target.path) + if (!parseResult.config) { + return { + success: false, + configPath: target.path, + error: parseResult.error ?? "Failed to parse config file", + } + } + + const config = parseResult.config + const plugins = config.plugin ?? [] + const existingEntry = findOurPluginEntry(plugins) + const nextPluginEntry = choosePluginEntry({ + existingEntry, + fallbackEntry, + preferredSourceEntry, + }) + + if (existingEntry && !preferredSourceEntry) { + const installedVersion = extractVersionFromPluginEntry(existingEntry) + const compatibility = checkVersionCompatibility(installedVersion, currentVersion) + + if (!compatibility.canUpgrade) { + return { + success: false, + configPath: target.path, + error: compatibility.reason ?? "Version compatibility check failed", + } + } + + const backupResult = backupConfigFile(target.path) + if (!backupResult.success) { + return { + success: false, + configPath: target.path, + error: `Failed to create backup: ${backupResult.error}`, + } + } + } + + const normalizedPlugins = plugins.filter((plugin) => !isOurPlugin(plugin)) + normalizedPlugins.push(nextPluginEntry) + + config.plugin = normalizedPlugins + + if (target.format === "jsonc") { + const content = readFileSync(target.path, "utf-8") + const pluginArrayRegex = /((?:"plugin"|plugin)\s*:\s*)\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + + if (match) { + const formattedPlugins = normalizedPlugins.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `$1[\n ${formattedPlugins}\n ]`) + writeFileSync(target.path, newContent) + } else { + const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${nextPluginEntry}"],`) + writeFileSync(target.path, newContent) + } + } else { + writeFileSync(target.path, JSON.stringify(config, null, 2) + "\n") + } + + return { success: true, configPath: target.path } + } catch (err) { + return { + success: false, + configPath: target.path, + error: formatErrorWithSuggestion(err, "update opencode config"), + } + } +} + export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { ensureConfigDirectoryExists() @@ -21,91 +213,27 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise } } - const { format, path } = detectConfigFormat() + const primaryTarget = detectConfigFormat() + const targets = getConfigTargets() + const preferredSourceEntry = targets + .map((target) => findSourcePluginEntryInTarget(target)) + .find((entry): entry is string => entry !== null) ?? null const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME) - try { - if (format === "none") { - const config: OpenCodeConfig = { plugin: [pluginEntry] } - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - return { success: true, configPath: path } - } + let primaryResult: ConfigMergeResult | null = null + for (const target of targets) { + const result = writePluginEntryToTarget({ + target, + currentVersion, + fallbackEntry: pluginEntry, + preferredSourceEntry, + }) - const parseResult = parseOpenCodeConfigFileWithError(path) - if (!parseResult.config) { - return { - success: false, - configPath: path, - error: parseResult.error ?? "Failed to parse config file", - } - } - - const config = parseResult.config - const plugins = config.plugin ?? [] - - const canonicalEntries = plugins.filter( - (plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) - ) - const legacyEntries = plugins.filter( - (plugin) => plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) - ) - const otherPlugins = plugins.filter( - (plugin) => !(plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`)) - && !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)) - ) - - const existingEntry = canonicalEntries[0] ?? legacyEntries[0] - if (existingEntry) { - const installedVersion = extractVersionFromPluginEntry(existingEntry) - const compatibility = checkVersionCompatibility(installedVersion, currentVersion) - - if (!compatibility.canUpgrade) { - return { - success: false, - configPath: path, - error: compatibility.reason ?? "Version compatibility check failed", - } - } - - const backupResult = backupConfigFile(path) - if (!backupResult.success) { - return { - success: false, - configPath: path, - error: `Failed to create backup: ${backupResult.error}`, - } - } - } - - const normalizedPlugins = [...otherPlugins] - - normalizedPlugins.push(pluginEntry) - - config.plugin = normalizedPlugins - - if (format === "jsonc") { - const content = readFileSync(path, "utf-8") - const pluginArrayRegex = /((?:"plugin"|plugin)\s*:\s*)\[([\s\S]*?)\]/ - const match = content.match(pluginArrayRegex) - - if (match) { - const formattedPlugins = normalizedPlugins.map((p) => `"${p}"`).join(",\n ") - const newContent = content.replace(pluginArrayRegex, `$1[\n ${formattedPlugins}\n ]`) - writeFileSync(path, newContent) - } else { - const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${pluginEntry}"],`) - writeFileSync(path, newContent) - } - } else { - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - } - - return { success: true, configPath: path } - } catch (err) { - return { - success: false, - configPath: path, - error: formatErrorWithSuggestion(err, "update opencode config"), + if (!result.success) return result + if (target.primary) { + primaryResult = result } } + + return primaryResult ?? { success: true, configPath: primaryTarget.path } } diff --git a/src/cli/config-manager/plugin-detection.test.ts b/src/cli/config-manager/plugin-detection.test.ts index e4ebd1b6e..f9cd3fda5 100644 --- a/src/cli/config-manager/plugin-detection.test.ts +++ b/src/cli/config-manager/plugin-detection.test.ts @@ -184,4 +184,56 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { expect(savedContent.includes('"plugin": [\n "oh-my-openagent"\n ]')).toBe(true) expect(savedContent.includes("oh-my-opencode")).toBe(false) }) + + it("mirrors an existing source plugin entry into profile configs", async () => { + // given + const sourcePlugin = "file:///Users/yeongyu/local-workspaces/omo/src/index.ts" + writeFileSync(testConfigPath, JSON.stringify({ plugin: [sourcePlugin] }, null, 2) + "\n", "utf-8") + + const profileDir = join(testConfigDir, "profiles", "today") + const profileConfigPath = join(profileDir, "opencode.json") + mkdirSync(profileDir, { recursive: true }) + writeFileSync( + profileConfigPath, + JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n", + "utf-8", + ) + + // when + const result = await addPluginToOpenCodeConfig("3.11.0") + + // then + expect(result.success).toBe(true) + const savedRootConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) + const savedProfileConfig = JSON.parse(readFileSync(profileConfigPath, "utf-8")) + expect(savedRootConfig.plugin).toEqual([sourcePlugin]) + expect(savedProfileConfig.plugin).toEqual([sourcePlugin]) + }) + + it("uses the parent source plugin entry when OPENCODE_CONFIG_DIR points at a profile", async () => { + // given + const sourcePlugin = "file:///Users/yeongyu/local-workspaces/omo/src/index.ts" + writeFileSync(testConfigPath, JSON.stringify({ plugin: [sourcePlugin] }, null, 2) + "\n", "utf-8") + + const profileDir = join(testConfigDir, "profiles", "today") + const profileConfigPath = join(profileDir, "opencode.json") + mkdirSync(profileDir, { recursive: true }) + writeFileSync( + profileConfigPath, + JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n", + "utf-8", + ) + + process.env.OPENCODE_CONFIG_DIR = profileDir + resetConfigContext() + + // when + const result = await addPluginToOpenCodeConfig("3.11.0") + + // then + expect(result.success).toBe(true) + expect(result.configPath.endsWith("/profiles/today/opencode.json")).toBe(true) + const savedProfileConfig = JSON.parse(readFileSync(profileConfigPath, "utf-8")) + expect(savedProfileConfig.plugin).toEqual([sourcePlugin]) + }) }) 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/AGENTS.md b/src/features/AGENTS.md index 439782e85..ee7bc82fb 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -16,7 +16,7 @@ File counts are NON-TEST `.ts` files only (test files co-located but excluded fr | **background-agent** | 30 / 1 subdir (spawner/) | HIGH | yes | Task lifecycle, concurrency (5/key), 3s polling, spawner pattern, circuit breaker. Newer files: `parent-wake-notifier.ts` (587 LOC), `loop-detector`, `error-classifier`, `fallback-retry-handler`, `process-cleanup`, `subagent-spawn-limits`, `session-status-classifier`, `compaction-aware-message-resolver`. | | **tmux-subagent** | 27 | HIGH | yes | Tmux pane management, grid planning, session orchestration via `runTmuxCommand` | | **opencode-skill-loader** | 25 / 1 subdir (merger/) | HIGH | yes | YAML frontmatter skill discovery from 4 scopes (project > opencode > user > global) | -| **builtin-skills** | 18 / 5 subdirs | LOW–MED | yes | 10 built-in skill files (git-master, playwright, frontend-ui-ux, review-work, ai-slop-remover, dev-browser, playwright-cli, **team-mode**, …) | +| **builtin-skills** | 20 / 6 subdirs | LOW–MED | yes | 12 built-in skill files (git-master, playwright, frontend-ui-ux, review-work, ai-slop-remover, security-research, security-review, dev-browser, playwright-cli, **team-mode**, …) | | **skill-mcp-manager** | 11 | HIGH | yes | Tier-3 MCP client lifecycle per session (stdio + HTTP + OAuth) | | **claude-code-plugin-loader** | 11 | MEDIUM | yes | Unified Claude Code plugin discovery (commands, agents, skills, hooks, MCPs) | | **builtin-commands** | 11 / 1 subdir (templates/) | LOW | yes | Command templates: refactor, init-deep, handoff, ulw-loop, etc. | @@ -70,7 +70,7 @@ Eligible members: sisyphus, atlas, sisyphus-junior, hephaestus only. See [`team- State-first tmux integration. Centralized tmux command execution through `src/shared/tmux/runner.ts` (`runTmuxCommand`). Direct `Bun.spawn(["tmux", ...])` is FORBIDDEN — would drift from retry/timeout discipline. -### builtin-skills (10 skills) +### builtin-skills (12 skills) | Skill | LOC | MCP | Notes | |-------|-----|-----|-------| @@ -80,6 +80,8 @@ State-first tmux integration. Centralized tmux command execution through `src/sh | dev-browser | 221 | — | Persistent page state browser | | review-work | ~500 | — | 5-agent post-implementation review orchestrator | | ai-slop-remover | ~300 | — | Remove AI code patterns | +| security-research | SKILL.md | — | Team Mode exploitability-driven security research | +| security-review | wrapper | — | Alias for security-research | | **team-mode** | — | — | Loaded only when `team_mode.enabled` (skill explains the 12 tools to agents) | | frontend-ui-ux | 79 | — | Design-first UI development | | (git-master-skill-metadata) | — | — | Companion to git-master | diff --git a/src/features/builtin-skills/AGENTS.md b/src/features/builtin-skills/AGENTS.md index 5083534ab..1aa1f464f 100644 --- a/src/features/builtin-skills/AGENTS.md +++ b/src/features/builtin-skills/AGENTS.md @@ -1,4 +1,4 @@ -# src/features/builtin-skills/ — 10 Built-in Skill Files +# src/features/builtin-skills/ — 12 Built-in Skill Files **Generated:** 2026-05-15 @@ -11,7 +11,7 @@ Skills shipped inside the plugin (always available, no install). Registered via ``` builtin-skills/ ├── index.ts # Barrel exports -├── skills.ts # createBuiltinSkills() factory — registers all 10 below +├── skills.ts # createBuiltinSkills() factory — registers all 12 below ├── types.ts # BuiltinSkill interface ├── skills/ │ ├── git-master.ts # 1111 LOC @@ -23,12 +23,15 @@ builtin-skills/ │ ├── review-work.ts # 5-agent post-implementation review │ ├── ai-slop-remover.ts # Remove AI-generated code patterns │ ├── team-mode.ts # 12 team_* tool documentation (gated) +│ ├── security-research.ts # Team Mode exploitability-driven security research +│ ├── security-review.ts # Alias for security-research │ ├── git-master-sections/ # Git-master prompt sub-sections │ └── index.ts # skill barrel ├── git-master/ # Resources for git-master skill ├── frontend-ui-ux/ # Resources for frontend-ui-ux skill ├── agent-browser/ # Resources for agent-browser variant -└── dev-browser/ # Resources for dev-browser +├── dev-browser/ # Resources for dev-browser +└── security-research/ # Resources for security-research ``` ## SKILL CATALOG @@ -43,6 +46,8 @@ builtin-skills/ | `frontend-ui-ux` | 79 | — | Design-first UI development guidance | | `review-work` | ~500 | — | Post-implementation review orchestrator (5 parallel agents) | | `ai-slop-remover` | ~300 | — | Remove AI-generated code smells | +| `security-research` | SKILL.md | — | Team Mode exploitability-driven security research | +| `security-review` | wrapper | — | Alias for `security-research` | | `team-mode` | — | — | **Conditional** — only loaded when `team_mode.enabled`; documents the 12 `team_*` tools and lifecycle | ## BROWSER VARIANT SELECTION diff --git a/src/features/builtin-skills/security-research/SKILL.md b/src/features/builtin-skills/security-research/SKILL.md new file mode 100644 index 000000000..35fe81a61 --- /dev/null +++ b/src/features/builtin-skills/security-research/SKILL.md @@ -0,0 +1,198 @@ +# Security Research - Team Mode Vulnerability Audit + +Use this skill to run a parallel security audit that separates real exploitability from generic concern. The team has 3 vulnerability hunters and 2 PoC engineers. + +## Hard Preconditions + +Before starting, verify: + +1. `team_*` tools are available. If not, stop and tell the user: + `security-research requires team-mode. Set team_mode.enabled: true in your oh-my-openagent config, restart opencode, then retry.` +2. You are in the main session, not a background subagent. +3. You have a concrete target: repository, diff range, PR, release candidate, path list, or threat surface. + +If the user provided no target, audit the current repository and current branch diff against its upstream or merge base. If there is no diff, audit the security-sensitive surfaces in the working tree. + +## Severity Standard + +Use these references as the scoring frame: + +- CWE for root-cause weakness classification: https://cwe.mitre.org/ +- OWASP WSTG for test methodology: https://devguide.owasp.org/en/06-verification/01-guides/01-wstg/ +- OWASP ASVS for control verification: https://owasp.org/www-project-application-security-verification-standard/ +- CVSS v4.0 for exploitability and impact scoring: https://www.first.org/cvss/v4.0/specification-document + +Rules: + +- No severity without an attack path. +- No critical or high finding without concrete exploit preconditions and impact. +- Keep CWE category separate from severity. +- Prefer a small, reproducible PoC over theoretical language. +- Never run destructive exploits against real services or third-party systems. +- Use local fixtures, toy payloads, dry runs, or static proof when real execution would be unsafe. + +## Team Roster + +Create one Team Mode run with these 5 members: + +| Member | Kind | Category | Role | +|--------|------|----------|------| +| `surface-hunter` | category | `deep` | Map entry points, trust boundaries, and reachable attack surfaces. | +| `auth-data-hunter` | category | `ultrabrain` | Hunt auth, authorization, data isolation, injection, and secret handling flaws. | +| `runtime-supply-hunter` | category | `unspecified-high` | Hunt filesystem, subprocess, archive, dependency, hook, MCP, and config risks. | +| `poc-engineer-a` | category | `unspecified-high` | Build minimal PoCs for the strongest candidate findings. | +| `poc-engineer-b` | category | `deep` | Independently reproduce, falsify, or downgrade candidate findings. | + +Call `team_create` with an inline spec: + +```typescript +team_create({ + inline_spec: { + name: "security-research", + description: "Parallel exploitability-driven security research team.", + members: [ + { + name: "surface-hunter", + kind: "category", + category: "deep", + prompt: "You map attack surface. Enumerate entry points, trust boundaries, attacker-controlled inputs, data sinks, privilege transitions, and sensitive assets. Return evidence with file paths and exact functions. Do not assign severity unless you can name an attack path." + }, + { + name: "auth-data-hunter", + kind: "category", + category: "ultrabrain", + prompt: "You hunt auth, authorization, tenant/data isolation, injection, SSRF, credential exposure, and confused-deputy flaws. Reason from attacker capability to impact. Return only findings with concrete exploit preconditions, CWE candidates, and verification steps." + }, + { + name: "runtime-supply-hunter", + kind: "category", + category: "unspecified-high", + prompt: "You hunt filesystem, subprocess, archive extraction, dependency, hook execution, MCP, config, and environment-variable risks. Check path traversal, command injection, unsafe downloads, permission boundaries, and supply-chain assumptions. Cite file paths and commands used." + }, + { + name: "poc-engineer-a", + kind: "category", + category: "unspecified-high", + prompt: "You build minimal safe PoCs for candidate findings. Use toy inputs and local-only execution. Your job is to prove or disprove exploitability, not to broaden scope. Report exact reproduction steps and expected output." + }, + { + name: "poc-engineer-b", + kind: "category", + category: "deep", + prompt: "You independently reproduce candidate findings and try to falsify them. Downgrade anything without a working path. If a PoC is unsafe to run, design a safe static or dry-run proof and explain the limit." + } + ] + } +}) +``` + +If a category is unavailable, retry once by replacing only that category with `unspecified-high`. Do not reduce the team below 5 members. + +## Workflow + +### Phase 0: Scope and Baseline + +Collect: + +- Target scope and reason for audit. +- Branch, base ref, diff, and changed files if this is a change review. +- Security-sensitive directories and files if this is a full-repo audit. +- Existing tests and commands that exercise relevant surfaces. +- Any user-stated constraints, such as no network calls or no destructive tests. + +Use `rg`, `git diff`, `git log`, LSP, and existing tests before assigning work. + +### Phase 1: Independent Hunter Pass + +Send one prompt to the 3 hunters: + +```text +Audit target: +{target summary} + +Context: +{diff, file list, security-sensitive paths, known constraints} + +Task: +Find candidate vulnerabilities in your assigned role. For each candidate include: +- title +- affected file/function +- attacker capability +- attack path +- impact +- CWE candidate +- exact evidence +- safe verification idea + +Reject generic hardening advice. Return only candidates with a plausible path. +``` + +Wait for all hunters. + +### Phase 2: PoC Pass + +Deduplicate hunter candidates. Send the strongest candidates to both PoC engineers. + +Each PoC engineer must return: + +- Reproduced, falsified, or unsafe-to-run. +- Exact commands, fixtures, or static proof. +- Observed output or reason it fails. +- Severity recommendation using exploitability and impact. +- Downgrade rationale for anything not reproduced. + +### Phase 3: Cross-Check + +Send the PoC results back to all 5 members. + +Ask every member: + +- Which findings survive? +- Which findings should be downgraded or removed? +- What remediation is smallest and specific? +- What regression test would prevent recurrence? + +### Phase 4: Final Report + +Produce this report: + +```markdown +## Security Research Result + +### Verdict +PASS | PASS WITH FINDINGS | BLOCK + +### Scope +- Target: +- Base/diff: +- Commands run: + +### Findings +| Severity | Title | CWE | Exploitability | Impact | PoC | Fix | +|----------|-------|-----|----------------|--------|-----|-----| + +### Finding Details +For each finding: +- Evidence: +- Attack path: +- PoC: +- Severity rationale: +- Minimal fix: +- Regression check: + +### Downgraded or Rejected Candidates +| Candidate | Reason | +|-----------|--------| + +### Residual Risk +- What was not tested and why. +``` + +## Output Rules + +- Lead with the verdict. +- Do not bury blocking issues. +- Do not report speculative findings as vulnerabilities. +- Do not claim CVSS precision unless you actually scored the metrics. +- Include exact file paths and commands for every surviving finding. +- If no findings survive PoC, say that plainly and list residual risk. diff --git a/src/features/builtin-skills/skills.test.ts b/src/features/builtin-skills/skills.test.ts index 5b17c6319..04647223c 100644 --- a/src/features/builtin-skills/skills.test.ts +++ b/src/features/builtin-skills/skills.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, test, expect } from "bun:test" import { createBuiltinSkills } from "./skills" @@ -11,8 +13,8 @@ describe("createBuiltinSkills", () => { // then const browserSkill = skills.find((s) => s.name === "playwright") expect(browserSkill).toBeDefined() - expect(browserSkill!.description).toContain("browser") - expect(browserSkill!.mcpConfig?.playwright).toBeDefined() + expect(browserSkill?.description).toContain("browser") + expect(browserSkill?.mcpConfig?.playwright).toBeDefined() }) test("returns playwright skill when browserProvider is 'playwright'", () => { @@ -44,7 +46,7 @@ describe("createBuiltinSkills", () => { const playwrightSkill = skills.find((skill) => skill.name === "playwright") const agentBrowserSkill = skills.find((skill) => skill.name === "agent-browser") expect(devBrowserSkill).toBeDefined() - expect(devBrowserSkill!.description).toContain("Browser automation") + expect(devBrowserSkill?.description).toContain("Browser automation") expect(playwrightSkill).toBeUndefined() expect(agentBrowserSkill).toBeUndefined() expect(skillNames).not.toContain("playwright-cli") @@ -62,9 +64,9 @@ describe("createBuiltinSkills", () => { const agentBrowserSkill = skills.find((s) => s.name === "agent-browser") const playwrightSkill = skills.find((s) => s.name === "playwright") expect(agentBrowserSkill).toBeDefined() - expect(agentBrowserSkill!.description).toContain("browser") - expect(agentBrowserSkill!.allowedTools).toContain("Bash(agent-browser:*)") - expect(agentBrowserSkill!.template).toContain("agent-browser") + expect(agentBrowserSkill?.description).toContain("browser") + expect(agentBrowserSkill?.allowedTools).toContain("Bash(agent-browser:*)") + expect(agentBrowserSkill?.template).toContain("agent-browser") expect(playwrightSkill).toBeUndefined() }) @@ -77,13 +79,13 @@ describe("createBuiltinSkills", () => { const agentBrowserSkill = skills.find((s) => s.name === "agent-browser") // then - template should contain substantial content (inlined, not fallback) - expect(agentBrowserSkill!.template).toContain("## Quick start") - expect(agentBrowserSkill!.template).toContain("## Commands") - expect(agentBrowserSkill!.template).toContain("agent-browser open") - expect(agentBrowserSkill!.template).toContain("agent-browser snapshot") + expect(agentBrowserSkill?.template).toContain("## Quick start") + expect(agentBrowserSkill?.template).toContain("## Commands") + expect(agentBrowserSkill?.template).toContain("agent-browser open") + expect(agentBrowserSkill?.template).toContain("agent-browser snapshot") }) - test("always includes frontend-ui-ux, git-master, review-work, and ai-slop-remover skills", () => { + test("always includes frontend-ui-ux, git-master, review-work, ai-slop-remover, security-research, and security-review skills", () => { // given - both provider options // when @@ -97,10 +99,12 @@ describe("createBuiltinSkills", () => { expect(skills.find((s) => s.name === "git-master")).toBeDefined() expect(skills.find((s) => s.name === "review-work")).toBeDefined() expect(skills.find((s) => s.name === "ai-slop-remover")).toBeDefined() + expect(skills.find((s) => s.name === "security-research")).toBeDefined() + expect(skills.find((s) => s.name === "security-review")).toBeDefined() } }) - test("returns exactly 5 skills regardless of provider", () => { + test("returns exactly 7 skills regardless of provider", () => { // given // when @@ -109,9 +113,9 @@ describe("createBuiltinSkills", () => { const devBrowserSkills = createBuiltinSkills({ browserProvider: "dev-browser" }) // then - expect(defaultSkills).toHaveLength(5) - expect(agentBrowserSkills).toHaveLength(5) - expect(devBrowserSkills).toHaveLength(5) + expect(defaultSkills).toHaveLength(7) + expect(agentBrowserSkills).toHaveLength(7) + expect(devBrowserSkills).toHaveLength(7) }) test("should exclude playwright when it is in disabledSkills", () => { @@ -128,7 +132,9 @@ describe("createBuiltinSkills", () => { expect(skills.map((s) => s.name)).not.toContain("dev-browser") expect(skills.map((s) => s.name)).toContain("review-work") expect(skills.map((s) => s.name)).toContain("ai-slop-remover") - expect(skills.length).toBe(4) + expect(skills.map((s) => s.name)).toContain("security-research") + expect(skills.map((s) => s.name)).toContain("security-review") + expect(skills.length).toBe(6) }) test("should exclude multiple skills when they are in disabledSkills", () => { @@ -145,12 +151,14 @@ describe("createBuiltinSkills", () => { expect(skills.map((s) => s.name)).not.toContain("dev-browser") expect(skills.map((s) => s.name)).toContain("review-work") expect(skills.map((s) => s.name)).toContain("ai-slop-remover") - expect(skills.length).toBe(3) + expect(skills.map((s) => s.name)).toContain("security-research") + expect(skills.map((s) => s.name)).toContain("security-review") + expect(skills.length).toBe(5) }) test("should return an empty array when all skills are disabled", () => { // #given - const options = { disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "review-work", "ai-slop-remover"]) } + const options = { disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "review-work", "ai-slop-remover", "security-research", "security-review"]) } // #when const skills = createBuiltinSkills(options) @@ -167,7 +175,7 @@ describe("createBuiltinSkills", () => { const skills = createBuiltinSkills(options) // #then - expect(skills.length).toBe(5) + expect(skills.length).toBe(7) }) test("review-work skill has correct structure", () => { @@ -179,13 +187,13 @@ describe("createBuiltinSkills", () => { // #then expect(reviewWork).toBeDefined() - expect(reviewWork!.description).toContain("review") - expect(reviewWork!.template).toContain("5-Agent Parallel Review Orchestrator") - expect(reviewWork!.template).toContain("Goal & Constraint Verification") - expect(reviewWork!.template).toContain("QA") - expect(reviewWork!.template).toContain("Code Quality") - expect(reviewWork!.template).toContain("Security") - expect(reviewWork!.template).toContain("Context Mining") + expect(reviewWork?.description).toContain("review") + expect(reviewWork?.template).toContain("5-Agent Parallel Review Orchestrator") + expect(reviewWork?.template).toContain("Goal & Constraint Verification") + expect(reviewWork?.template).toContain("QA") + expect(reviewWork?.template).toContain("Code Quality") + expect(reviewWork?.template).toContain("Security") + expect(reviewWork?.template).toContain("Context Mining") }) test("review-work skill explains Codex tool compatibility before OpenCode orchestration examples", () => { @@ -194,8 +202,9 @@ describe("createBuiltinSkills", () => { // #when const reviewWork = skills.find((s) => s.name === "review-work") - const compatibilityIndex = reviewWork!.template.indexOf("## Codex Harness Tool Compatibility") - const opencodeExampleIndex = reviewWork!.template.search(/\b(?:background_output|team_[a-z_]+|task)\s*\(/) + const reviewWorkTemplate = reviewWork?.template ?? "" + const compatibilityIndex = reviewWorkTemplate.indexOf("## Codex Harness Tool Compatibility") + const opencodeExampleIndex = reviewWorkTemplate.search(/\b(?:background_output|team_[a-z_]+|task)\s*\(/) // #then expect(compatibilityIndex >= 0).toBe(true) @@ -211,9 +220,37 @@ describe("createBuiltinSkills", () => { // #then expect(aiSlopRemover).toBeDefined() - expect(aiSlopRemover!.description).toContain("AI-generated code smells") - expect(aiSlopRemover!.template).toContain("DETECTION CRITERIA") - expect(aiSlopRemover!.template).toContain("SAFETY RULES") + expect(aiSlopRemover?.description).toContain("AI-generated code smells") + expect(aiSlopRemover?.template).toContain("DETECTION CRITERIA") + expect(aiSlopRemover?.template).toContain("SAFETY RULES") + }) + + test("security-research skill has correct structure", () => { + // #given - default options + + // #when + const skills = createBuiltinSkills() + const securityResearch = skills.find((skill) => skill.name === "security-research") + + // #then + expect(securityResearch?.description).toContain("security research") + expect(securityResearch?.template).toContain("Security Research - Team Mode Vulnerability Audit") + expect(securityResearch?.template).toContain('name: "security-research"') + expect(securityResearch?.template).toContain("Security Research Result") + }) + + test("security-review skill remains a runtime alias for security-research", () => { + // #given - default options + + // #when + const skills = createBuiltinSkills() + const securityReview = skills.find((skill) => skill.name === "security-review") + const securityResearch = skills.find((skill) => skill.name === "security-research") + + // #then + expect(securityReview?.description).toContain("Alias for security-research") + expect(securityReview?.description).toContain("/security-review") + expect(securityReview?.template).toBe(securityResearch?.template) }) test("returns playwright-cli skill when browserProvider is 'playwright-cli'", () => { @@ -227,9 +264,9 @@ describe("createBuiltinSkills", () => { const playwrightSkill = skills.find((s) => s.name === "playwright") const agentBrowserSkill = skills.find((s) => s.name === "agent-browser") expect(playwrightSkill).toBeDefined() - expect(playwrightSkill!.description).toContain("browser") - expect(playwrightSkill!.allowedTools).toContain("Bash(playwright-cli:*)") - expect(playwrightSkill!.mcpConfig).toBeUndefined() + expect(playwrightSkill?.description).toContain("browser") + expect(playwrightSkill?.allowedTools).toContain("Bash(playwright-cli:*)") + expect(playwrightSkill?.mcpConfig).toBeUndefined() expect(agentBrowserSkill).toBeUndefined() }) @@ -242,8 +279,8 @@ describe("createBuiltinSkills", () => { const skill = skills.find((s) => s.name === "playwright") // then - expect(skill!.template).toContain("playwright-cli open") - expect(skill!.template).toContain("playwright-cli snapshot") - expect(skill!.template).toContain("playwright-cli click") + expect(skill?.template).toContain("playwright-cli open") + expect(skill?.template).toContain("playwright-cli snapshot") + expect(skill?.template).toContain("playwright-cli click") }) }) diff --git a/src/features/builtin-skills/skills.ts b/src/features/builtin-skills/skills.ts index 8c544e186..f4c4c60cf 100644 --- a/src/features/builtin-skills/skills.ts +++ b/src/features/builtin-skills/skills.ts @@ -10,6 +10,8 @@ import { devBrowserSkill, reviewWorkSkill, aiSlopRemoverSkill, + securityResearchSkill, + securityReviewSkill, teamModeSkill, } from "./skills/index" @@ -33,7 +35,15 @@ export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): B browserSkill = playwrightSkill } - const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, reviewWorkSkill, aiSlopRemoverSkill] + const skills = [ + browserSkill, + frontendUiUxSkill, + gitMasterSkill, + reviewWorkSkill, + aiSlopRemoverSkill, + securityResearchSkill, + securityReviewSkill, + ] if (teamModeEnabled && !disabledSkills?.has("team-mode")) { skills.push(teamModeSkill) diff --git a/src/features/builtin-skills/skills/index.ts b/src/features/builtin-skills/skills/index.ts index 2990cf178..736b64831 100644 --- a/src/features/builtin-skills/skills/index.ts +++ b/src/features/builtin-skills/skills/index.ts @@ -5,4 +5,6 @@ export { gitMasterSkill } from "./git-master" export { devBrowserSkill } from "./dev-browser" export { reviewWorkSkill } from "./review-work" export { aiSlopRemoverSkill } from "./ai-slop-remover" +export { securityResearchSkill } from "./security-research" +export { securityReviewSkill } from "./security-review" export * from "./team-mode" diff --git a/src/features/builtin-skills/skills/security-research.ts b/src/features/builtin-skills/skills/security-research.ts new file mode 100644 index 000000000..f190bf13b --- /dev/null +++ b/src/features/builtin-skills/skills/security-research.ts @@ -0,0 +1,9 @@ +import type { BuiltinSkill } from "../types" +import securityResearchTemplate from "../security-research/SKILL.md" with { type: "text" } + +export const securityResearchSkill: BuiltinSkill = { + name: "security-research", + description: + "Team Mode security research skill. Orchestrates 3 vulnerability hunters and 2 PoC engineers to audit a codebase in parallel, prove exploitability, classify root causes, and calibrate severity by actual exploitability. Use for security review, vulnerability research, exploitability audit, pre-release security check, threat model validation, and `/security-research`. Triggers: 'security-research', 'security research', 'security review', 'vulnerability audit', 'exploitability audit', '보안 리뷰', '취약점 감사'.", + template: securityResearchTemplate, +} diff --git a/src/features/builtin-skills/skills/security-review.ts b/src/features/builtin-skills/skills/security-review.ts new file mode 100644 index 000000000..864f90c30 --- /dev/null +++ b/src/features/builtin-skills/skills/security-review.ts @@ -0,0 +1,8 @@ +import type { BuiltinSkill } from "../types" +import { securityResearchSkill } from "./security-research" + +export const securityReviewSkill: BuiltinSkill = { + name: "security-review", + description: `Alias for security-research and /security-review. ${securityResearchSkill.description}`, + template: securityResearchSkill.template, +} 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/hooks/session-recovery/detect-error-type.test.ts b/src/hooks/session-recovery/detect-error-type.test.ts index f3fdccfa8..7652d63d5 100644 --- a/src/hooks/session-recovery/detect-error-type.test.ts +++ b/src/hooks/session-recovery/detect-error-type.test.ts @@ -61,6 +61,17 @@ describe("detectErrorType", () => { expect(result).toBe("thinking_block_modified") }) + it("#given a modified-thinking error also says expected and found #when detecting #then returns thinking_block_modified", () => { + const error = { + message: + "messages.3.content.3: Expected `thinking` or `redacted_thinking`, but found `tool_use`. `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.", + } + + const result = detectErrorType(error) + + expect(result).toBe("thinking_block_modified") + }) + it("#given an unrecognized error #when detecting #then returns null", () => { //#given const error = { message: "some random error" } diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts index ef849a6c7..456f4f923 100644 --- a/src/hooks/session-recovery/detect-error-type.ts +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -66,6 +66,10 @@ export function detectErrorType(error: unknown): RecoveryErrorType { return "assistant_prefill_unsupported" } + if (message.includes("thinking") && message.includes("cannot be modified")) { + return "thinking_block_modified" + } + if ( message.includes("thinking") && (message.includes("first block") || @@ -78,11 +82,6 @@ export function detectErrorType(error: unknown): RecoveryErrorType { return "thinking_block_order" } - // Thinking block signature corruption (Bedrock compaction) - if (message.includes("thinking") && message.includes("cannot be modified")) { - return "thinking_block_modified" - } - if (message.includes("thinking is disabled") && message.includes("cannot contain")) { return "thinking_disabled_violation" } diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts index d9521f4be..4961cea19 100644 --- a/src/hooks/session-recovery/hook.ts +++ b/src/hooks/session-recovery/hook.ts @@ -194,6 +194,30 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec let shouldKeepProcessingError = false try { + if (errorType === "thinking_block_modified") { + shouldKeepProcessingError = true + log("[session-recovery] Refusing to mutate latest assistant thinking blocks", { + sessionID, + assistantMsgID, + }) + await ctx.client.tui + .showToast({ + body: { + title: "Thinking Block Recovery", + message: "Latest assistant thinking blocks cannot be safely recovered; leaving history unchanged.", + variant: "warning", + duration: 3000, + }, + }) + .catch((error: unknown) => { + log("[session-recovery] Failed to show thinking block modified toast", { + sessionID, + error, + }) + }) + return false + } + if (onAbortCallback) { onAbortCallback(sessionID) } @@ -224,7 +248,7 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec unavailable_tool: "Recovering from unavailable tool call...", thinking_block_order: "Fixing message structure...", thinking_disabled_violation: "Stripping thinking blocks...", - thinking_block_modified: "Stripping corrupted thinking blocks...", + thinking_block_modified: "Leaving latest thinking blocks unchanged...", "assistant_prefill_unsupported": "Prefill not supported; continuing without recovery.", } @@ -261,13 +285,6 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec const resumeConfig = extractResumeConfig(lastUser, sessionID) await resumeSession(ctx.client, resumeConfig) } - } else if (errorType === "thinking_block_modified") { - success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } } else if (errorType === "assistant_prefill_unsupported") { shouldKeepProcessingError = true success = false diff --git a/src/hooks/session-recovery/storage/latest-assistant-message.ts b/src/hooks/session-recovery/storage/latest-assistant-message.ts new file mode 100644 index 000000000..90ff6cc86 --- /dev/null +++ b/src/hooks/session-recovery/storage/latest-assistant-message.ts @@ -0,0 +1,42 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { MessageData } from "../types" +import { log, normalizeSDKResponse } from "../../../shared" +import { readMessages } from "./messages-reader" + +type OpencodeClient = PluginInput["client"] + +export function isLatestAssistantMessage(sessionID: string, messageID: string): boolean { + const messages = readMessages(sessionID) + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role === "assistant") { + return message.id === messageID + } + } + return false +} + +export async function isLatestAssistantMessageFromSDK( + client: OpencodeClient, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.info?.role === "assistant") { + return message.info.id === messageID + } + } + } catch (error) { + log("[session-recovery] latest assistant lookup failed", { + sessionID, + messageID, + error: String(error), + }) + } + + return false +} diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts index 9ccb7131c..41593951a 100644 --- a/src/hooks/session-recovery/storage/thinking-prepend.ts +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -5,8 +5,8 @@ import { PART_STORAGE, THINKING_TYPES } from "../constants" import type { MessageData, StoredPart } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" -import { log, isSqliteBackend, patchPart } from "../../../shared" -import { normalizeSDKResponse } from "../../../shared" +import { log, isSqliteBackend, normalizeSDKResponse, patchPart } from "../../../shared" +import { isLatestAssistantMessage, isLatestAssistantMessageFromSDK } from "./latest-assistant-message" type OpencodeClient = PluginInput["client"] type StoredSignedThinkingPart = StoredPart & { @@ -28,6 +28,8 @@ type ThinkingPrependDeps = { findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK readTargetPartIDs: typeof readTargetPartIDs readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK + isLatestAssistantMessage?: typeof isLatestAssistantMessage + isLatestAssistantMessageFromSDK?: typeof isLatestAssistantMessageFromSDK } const thinkingPrependDeps: ThinkingPrependDeps = { @@ -38,6 +40,8 @@ const thinkingPrependDeps: ThinkingPrependDeps = { findLastThinkingPartFromSDK, readTargetPartIDs, readTargetPartIDsFromSDK, + isLatestAssistantMessage, + isLatestAssistantMessageFromSDK, } function readTargetPartIDs(messageID: string): string[] { @@ -137,6 +141,14 @@ export function prependThinkingPart( return false } + if (deps.isLatestAssistantMessage?.(sessionID, messageID) === true) { + deps.log("[session-recovery] Refusing to prepend thinking into latest assistant message", { + sessionID, + messageID, + }) + return false + } + const previousThinkingPart = deps.findLastThinkingPart(sessionID, messageID) if (!previousThinkingPart) { return false @@ -198,6 +210,17 @@ export async function prependThinkingPartAsync( messageID: string, deps: ThinkingPrependDeps = thinkingPrependDeps ): Promise { + const isLatestAssistant = deps.isLatestAssistantMessageFromSDK + ? await deps.isLatestAssistantMessageFromSDK(client, sessionID, messageID) + : false + if (isLatestAssistant) { + deps.log("[session-recovery] Refusing to patch thinking into latest assistant message", { + sessionID, + messageID, + }) + return false + } + const previousThinkingPart = await deps.findLastThinkingPartFromSDK(client, sessionID, messageID) if (!previousThinkingPart) { return false diff --git a/src/hooks/session-recovery/thinking-block-modified-recovery.test.ts b/src/hooks/session-recovery/thinking-block-modified-recovery.test.ts new file mode 100644 index 000000000..0a7665aca --- /dev/null +++ b/src/hooks/session-recovery/thinking-block-modified-recovery.test.ts @@ -0,0 +1,100 @@ +/// +import { afterEach, describe, expect, test } from "bun:test" +import { createSessionRecoveryHook } from "./hook" +import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate" + +type RecoverableInfo = Parameters["handleSessionRecovery"]>[0] + +describe("session-recovery immutable thinking block errors", () => { + afterEach(() => { + releaseAllPromptAsyncReservationsForTesting() + }) + + test("#given Anthropic rejects modified latest assistant thinking blocks #when recovery handles the error #then it leaves the session history untouched", async () => { + //#given + const counts = { + abort: 0, + abortCallback: 0, + messages: 0, + promptAsync: 0, + toast: 0, + } + const info: RecoverableInfo = { + id: "msg_failed_modified_thinking", + role: "assistant", + sessionID: "ses_modified_thinking", + error: { + message: + "messages.3.content.3: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.", + }, + } + const ctx = { + client: { + session: { + abort: async () => { + counts.abort++ + return {} + }, + messages: async () => { + counts.messages++ + return { + data: [ + { + info: { + id: info.id, + role: "assistant", + error: info.error, + }, + parts: [ + { + id: "prt_reasoning", + type: "reasoning", + text: "signed reasoning text", + metadata: { anthropic: { signature: "sig_reasoning" } }, + }, + { + id: "prt_redacted", + type: "redacted_thinking", + signature: "sig_redacted", + }, + { + id: "prt_text", + type: "text", + text: "assistant text", + }, + ], + }, + ], + } + }, + promptAsync: async () => { + counts.promptAsync++ + return {} + }, + }, + tui: { + showToast: async () => { + counts.toast++ + return {} + }, + }, + }, + directory: "/tmp/session-recovery-modified-thinking-test", + } + const hook = createSessionRecoveryHook(ctx as never) + hook.setOnAbortCallback(() => { + counts.abortCallback++ + }) + + //#when + const result = await hook.handleSessionRecovery(info) + + //#then + expect(result).toBe(false) + expect(counts.toast).toBe(1) + expect(counts.abortCallback).toBe(0) + expect(counts.abort).toBe(0) + expect(counts.messages).toBe(0) + expect(counts.promptAsync).toBe(0) + }) +}) diff --git a/src/hooks/session-recovery/thinking-prepend-latest.test.ts b/src/hooks/session-recovery/thinking-prepend-latest.test.ts new file mode 100644 index 000000000..23bf11fc9 --- /dev/null +++ b/src/hooks/session-recovery/thinking-prepend-latest.test.ts @@ -0,0 +1,110 @@ +/// +import { existsSync, rmSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterAll, describe, expect, it, mock } from "bun:test" + +const TEST_STORAGE_ROOT = join(tmpdir(), `session-recovery-latest-thinking-prepend-${randomUUID()}`) +const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part") + +mock.module("../../shared", () => ({ + OPENCODE_STORAGE: TEST_STORAGE_ROOT, + MESSAGE_STORAGE: join(TEST_STORAGE_ROOT, "message"), + PART_STORAGE: TEST_PART_STORAGE, + log: () => {}, + isSqliteBackend: () => false, + patchPart: async () => true, + normalizeSDKResponse: (response: { data?: TData }, fallback: TData) => response.data ?? fallback, +})) + +afterAll(() => { mock.restore() }) + +const { prependThinkingPart, prependThinkingPartAsync } = await import("./storage/thinking-prepend") + +type StoredPartRecord = { + id: string + sessionID: string + messageID: string + type: string + signature?: string + thinking?: string +} + +function cleanupParts(messageID: string): void { + rmSync(join(TEST_PART_STORAGE, messageID), { recursive: true, force: true }) +} + +describe("thinking-prepend latest assistant preservation", () => { + it("#given file-backed order recovery targets the latest assistant #when prepending thinking #then it refuses to write copied thinking", () => { + const sessionID = "ses_latest_file_backed_prepend" + const targetMessageID = "msg_latest_file_backed" + const previousThinkingPart = { + id: "prt_previous_thinking", + sessionID, + messageID: "msg_previous_assistant", + type: "thinking", + thinking: "prior signed thinking", + signature: "sig_previous", + } as const satisfies StoredPartRecord + const deps = { + isSqliteBackend: () => false, + patchPart: async () => true, + log: mock(() => {}), + findLastThinkingPart: () => previousThinkingPart, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => ["prt_target_text"], + readTargetPartIDsFromSDK: async () => [], + isLatestAssistantMessage: () => true, + isLatestAssistantMessageFromSDK: async () => false, + } + + const result = prependThinkingPart(sessionID, targetMessageID, deps) + + expect(result).toBe(false) + expect(existsSync(join(TEST_PART_STORAGE, targetMessageID))).toBe(false) + cleanupParts(targetMessageID) + }) + + it("#given sdk order recovery targets the latest assistant #when prepending thinking #then it refuses to patch copied thinking", async () => { + const sessionID = "ses_latest_sdk_prepend" + const targetMessageID = "msg_latest_sdk" + const patchPartMock = mock(async () => true) + const previousThinkingPart = { + id: "prt_previous_sdk_thinking", + type: "thinking", + thinking: "prior signed thinking", + signature: "sig_previous_sdk", + } as const + const client = { + session: { + messages: async () => ({ data: [] }), + }, + } + const deps = { + isSqliteBackend: () => false, + patchPart: patchPartMock, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => previousThinkingPart, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => ["prt_target_text"], + isLatestAssistantMessage: () => false, + isLatestAssistantMessageFromSDK: async () => true, + } + const prependThinkingPartAsyncUntyped = Reflect.get( + { prependThinkingPartAsync }, + "prependThinkingPartAsync", + ) + + const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [ + client, + sessionID, + targetMessageID, + deps, + ]) + + expect(result).toBe(false) + expect(patchPartMock).toHaveBeenCalledTimes(0) + }) +}) diff --git a/src/hooks/thinking-block-validator/hook.test.ts b/src/hooks/thinking-block-validator/hook.test.ts index 0601cbcfc..d2f3430d0 100644 --- a/src/hooks/thinking-block-validator/hook.test.ts +++ b/src/hooks/thinking-block-validator/hook.test.ts @@ -33,7 +33,7 @@ async function runTransform(messages: TestMessage[]): Promise { } describe("createThinkingBlockValidatorHook", () => { - it("injects signed thinking history verbatim", async () => { + it("does not copy signed thinking history into a later assistant message", async () => { //#given const signedThinkingPart: TestPart = { type: "thinking", @@ -55,10 +55,10 @@ describe("createThinkingBlockValidatorHook", () => { await runTransform(messages) //#then - expect(messages[1]?.parts[0]).toBe(signedThinkingPart) + expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }]) }) - it("injects signed redacted_thinking history verbatim", async () => { + it("does not copy signed redacted_thinking history into a later assistant message", async () => { //#given const signedRedactedThinkingPart: TestPart = { type: "redacted_thinking", @@ -79,7 +79,7 @@ describe("createThinkingBlockValidatorHook", () => { await runTransform(messages) //#then - expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart) + expect(messages[1]?.parts).toEqual([{ type: "tool_use" }]) }) it("skips hook when history contains reasoning only", async () => { diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts index af7a782d2..f187d65e5 100644 --- a/src/hooks/thinking-block-validator/hook.ts +++ b/src/hooks/thinking-block-validator/hook.ts @@ -1,19 +1,3 @@ -/** - * Proactive Thinking Block Validator Hook - * - * Prevents "Expected thinking/redacted_thinking but found tool_use" errors - * by validating and fixing message structure BEFORE sending to Anthropic API. - * - * This hook runs on the "experimental.chat.messages.transform" hook point, - * which is called before messages are converted to ModelMessage format and - * sent to the API. - * - * Key differences from session-recovery hook: - * - PROACTIVE (prevents error) vs REACTIVE (fixes after error) - * - Runs BEFORE API call vs AFTER API error - * - User never sees the error vs User sees error then recovery - */ - import type { Message, Part } from "@opencode-ai/sdk" interface MessageWithParts { @@ -28,155 +12,10 @@ type MessagesTransformHook = { ) => Promise } -type SignedThinkingPart = Part & { - type: "thinking" | "redacted_thinking" - thinking?: string - signature: string - synthetic?: boolean -} - -function isSignedThinkingPart(part: Part): part is SignedThinkingPart { - const type = part.type as string - if (type !== "thinking" && type !== "redacted_thinking") { - return false - } - - const signature = (part as { signature?: unknown }).signature - const synthetic = (part as { synthetic?: unknown }).synthetic - return typeof signature === "string" && signature.length > 0 && synthetic !== true -} - -/** - * Check if there are any Anthropic-signed thinking blocks in the message history. - * - * Only returns true for real `type: "thinking"` blocks with a valid `signature`. - * GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded - they - * have no Anthropic signature and must never be forwarded to the Anthropic API. - * - * Model-name checks are unreliable (miss GPT+thinking, custom model IDs, etc.) - * so we inspect the messages themselves. - */ -function hasSignedThinkingBlocksInHistory(messages: MessageWithParts[]): boolean { - return messages.some( - m => - m.info.role === "assistant" && - m.parts?.some((p: Part) => isSignedThinkingPart(p)), - ) -} - -/** - * Check if a message has any content parts (tool_use, text, or other non-thinking content) - */ -function hasContentParts(parts: Part[]): boolean { - if (!parts || parts.length === 0) return false - - return parts.some((part: Part) => { - const type = part.type as string - // Include tool parts and text parts (anything that's not thinking/reasoning) - return type === "tool" || type === "tool_use" || type === "text" - }) -} - -/** - * Check if a message already carries a thinking/reasoning block anywhere. - */ -function hasThinkingBlock(parts: Part[]): boolean { - if (!parts || parts.length === 0) return false - - return parts.some((part) => { - const type = part.type as string - return type === "thinking" || type === "redacted_thinking" || type === "reasoning" - }) -} - -/** - * Find the most recent Anthropic-signed thinking part from previous assistant messages. - * - * Returns the original Part object (including its `signature` field) so it can - * be reused verbatim in another message. Only `type: "thinking"` blocks with - * both a `signature` and `thinking` field are returned - GPT `type: "reasoning"` - * blocks are excluded because they lack an Anthropic signature and would be - * rejected by the API with "Invalid `signature` in `thinking` block". - * Synthetic parts injected by a previous run of this hook are also skipped. - */ -function findPreviousThinkingPart(messages: MessageWithParts[], currentIndex: number): SignedThinkingPart | null { - // Search backwards from current message - for (let i = currentIndex - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role !== "assistant") continue - if (!msg.parts) continue - - for (const part of msg.parts) { - // Only Anthropic thinking blocks - type must be "thinking", not "reasoning" - if (!isSignedThinkingPart(part)) continue - - return part - } - } - - return null -} - -/** - * Prepend an existing thinking block (with its original signature) to a - * message's parts array. - * - * We reuse the original Part verbatim instead of creating a new one, because - * the Anthropic API validates the `signature` field against the thinking - * content. Any synthetic block we create ourselves would fail that check. - */ -function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void { - if (!message.parts) { - message.parts = [] - } - - message.parts.unshift(thinkingPart) -} - -/** - * Validate and fix assistant messages that have tool_use but no thinking block - */ export function createThinkingBlockValidatorHook(): MessagesTransformHook { return { - "experimental.chat.messages.transform": async (_input, output) => { - const { messages } = output - - if (!messages || messages.length === 0) { - return - } - - // Skip if there are no Anthropic-signed thinking blocks in history. - // This is more reliable than checking model names - works for Claude, - // GPT with thinking variants, or any future model. Crucially, GPT - // reasoning blocks (type="reasoning", no signature) do NOT trigger this - // hook - only real Anthropic thinking blocks do. - if (!hasSignedThinkingBlocksInHistory(messages)) { - return - } - - // Process all assistant messages - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - - // Only check assistant messages - if (msg.info.role !== "assistant") continue - - // Check if message has content parts but no thinking block yet. - if (hasContentParts(msg.parts) && !hasThinkingBlock(msg.parts)) { - // Find the most recent real thinking part (with valid signature) from - // previous turns. If none exists we cannot safely inject a thinking - // block - a synthetic block without a signature would cause the API - // to reject the request with "Invalid `signature` in `thinking` block". - const previousThinkingPart = findPreviousThinkingPart(messages, i) - - if (previousThinkingPart) { - prependThinkingBlock(msg, previousThinkingPart) - } - // If no real thinking part is available, skip injection entirely. - // The downstream error (if any) is preferable to a guaranteed API - // rejection caused by a signature-less synthetic thinking block. - } - } + "experimental.chat.messages.transform": async () => { + return }, } } 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/plugin/messages-transform-thinking-block.test.ts b/src/plugin/messages-transform-thinking-block.test.ts index 81bd43092..13f398848 100644 --- a/src/plugin/messages-transform-thinking-block.test.ts +++ b/src/plugin/messages-transform-thinking-block.test.ts @@ -97,4 +97,53 @@ describe("messages transform thinking block integration", () => { expect(resumedMessage?.parts[1]).toBe(thinkingAfterAnswer) expect(countThinkingParts(resumedMessage?.parts ?? [])).toBe(1) }) + + it("#given prior signed thinking and a latest assistant turn without thinking #when messages transform runs #then it does not copy thinking into the latest turn", async () => { + //#given + const priorThinkingPart: TestPart = { + type: "thinking", + thinking: "prior plan", + signature: "sig-prior", + } + const latestTextPart: TestPart = { type: "text", text: "continue" } + const latestToolPart: TestPart = { + type: "tool_use", + id: "toolu_latest", + name: "bash", + } + const messages = [ + { + info: { id: "msg_user", role: "user", sessionID: "ses_latest_preserve" }, + parts: [{ type: "text", text: "start" }], + }, + { + info: { id: "msg_prior", role: "assistant", sessionID: "ses_latest_preserve" }, + parts: [priorThinkingPart, { type: "tool_use", id: "toolu_prior", name: "bash" }], + }, + { + info: { id: "msg_prior_result", role: "user", sessionID: "ses_latest_preserve" }, + parts: [ + { + type: "tool_result", + toolUseId: "toolu_prior", + tool_use_id: "toolu_prior", + content: [{ type: "text", text: "done" }], + }, + ], + }, + { + info: { id: "msg_latest", role: "assistant", sessionID: "ses_latest_preserve" }, + parts: [latestTextPart, latestToolPart], + }, + ] satisfies TestMessage[] + + //#when + await runMessagesTransform(messages) + + //#then + const latestMessage = messages.find((message) => message.info.id === "msg_latest") + expect(latestMessage?.parts[0]).toBe(latestTextPart) + expect(latestMessage?.parts[1]).toBe(latestToolPart) + expect(countThinkingParts(latestMessage?.parts ?? [])).toBe(0) + }) }) diff --git a/src/plugin/skill-context.test.ts b/src/plugin/skill-context.test.ts index 75397fb0b..9e8e06533 100644 --- a/src/plugin/skill-context.test.ts +++ b/src/plugin/skill-context.test.ts @@ -19,6 +19,75 @@ describe("createSkillContext", () => { rmSync(testDirectory, { recursive: true, force: true }) }) + it("exposes security skills to the OMO skill tool context", async () => { + // given + const discoverConfigSourceSkillsSpy = spyOn( + skillLoader, + "discoverConfigSourceSkills", + ).mockResolvedValue([]) + const discoverUserClaudeSkillsSpy = spyOn( + skillLoader, + "discoverUserClaudeSkills", + ).mockResolvedValue([]) + const discoverProjectClaudeSkillsSpy = spyOn( + skillLoader, + "discoverProjectClaudeSkills", + ).mockResolvedValue([]) + const discoverOpencodeGlobalSkillsSpy = spyOn( + skillLoader, + "discoverOpencodeGlobalSkills", + ).mockResolvedValue([]) + const discoverOpencodeProjectSkillsSpy = spyOn( + skillLoader, + "discoverOpencodeProjectSkills", + ).mockResolvedValue([]) + const discoverProjectAgentsSkillsSpy = spyOn( + skillLoader, + "discoverProjectAgentsSkills", + ).mockResolvedValue([]) + const discoverGlobalAgentsSkillsSpy = spyOn( + skillLoader, + "discoverGlobalAgentsSkills", + ).mockResolvedValue([]) + const getSystemMcpServerNamesSpy = spyOn( + mcpLoader, + "getSystemMcpServerNames", + ).mockReturnValue(new Set()) + + const pluginConfig = OhMyOpenCodeConfigSchema.parse({}) + + try { + // when + const result = await createSkillContext({ + directory: testDirectory, + pluginConfig, + }) + + // then + expect(result.mergedSkills.some((skill) => skill.name === "security-research")).toBe(true) + expect(result.mergedSkills.some((skill) => skill.name === "security-review")).toBe(true) + expect(result.availableSkills).toContainEqual({ + name: "security-research", + description: expect.stringContaining("security research"), + location: "plugin", + }) + expect(result.availableSkills).toContainEqual({ + name: "security-review", + description: expect.stringContaining("/security-review"), + location: "plugin", + }) + } finally { + discoverConfigSourceSkillsSpy.mockRestore() + discoverUserClaudeSkillsSpy.mockRestore() + discoverProjectClaudeSkillsSpy.mockRestore() + discoverOpencodeGlobalSkillsSpy.mockRestore() + discoverOpencodeProjectSkillsSpy.mockRestore() + discoverProjectAgentsSkillsSpy.mockRestore() + discoverGlobalAgentsSkillsSpy.mockRestore() + getSystemMcpServerNamesSpy.mockRestore() + } + }) + it("excludes discovered playwright skill when browser provider is agent-browser", async () => { // given const discoveredPlaywrightDir = join(testDirectory, ".claude", "skills", "playwright") 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 diff --git a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts index 60a4a2bbb..7ea3086e7 100644 --- a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts +++ b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts @@ -833,6 +833,31 @@ describe("skill tool - nativeSkills integration", () => { }) }) +describe("skill tool - bundled security skills", () => { + it("loads security-research and security-review when the plugin skill context pre-seeds them", async () => { + //#given + const { builtinToLoadedSkill } = await import("../../../features/opencode-skill-loader/merger/builtin-skill-converter") + const { securityResearchSkill, securityReviewSkill } = await import("../../../features/builtin-skills/skills/index") + const tool = createSkillTool({ + directory: "/test", + skills: [ + builtinToLoadedSkill(securityResearchSkill), + builtinToLoadedSkill(securityReviewSkill), + ], + }) + + //#when + const researchResult = await tool.execute({ name: "security-research" }, mockContext) + const reviewResult = await tool.execute({ name: "security-review" }, mockContext) + + //#then + expect(researchResult).toContain("## Skill: security-research") + expect(researchResult).toContain("Security Research - Team Mode Vulnerability Audit") + expect(reviewResult).toContain("## Skill: security-review") + expect(reviewResult).toContain("Security Research - Team Mode Vulnerability Audit") + }) +}) + describe("skill tool - short name resolution", () => { it("resolves namespaced skill by short name when unambiguous", async () => { // given