Merge remote-tracking branch 'origin/dev' into fix/bun-test-false-green-4058
This commit is contained in:
+12
-6
@@ -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 小时。它会一直工作直到任务完成。它是一个极度自律的智能体。" <br/>- B, 量化研究员
|
> "如果人类需要 3 个月完成的事情 Claude Code 需要 7 天,那么 Sisyphus 只需要 1 小时。它会一直工作直到任务完成。它是一个极度自律的智能体。" <br/>- B, 量化研究员
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
你同时折腾着 Claude Code、Codex、各种奇奇怪怪的开源模型。配工作流。给 Agent 调 Bug。
|
你同时折腾着 Claude Code、Codex、各种奇奇怪怪的开源模型。配工作流。给 Agent 调 Bug。
|
||||||
|
|
||||||
这些事我们替你做完了。全部测试过。只留下真正跑得起来的。
|
这些事我们替你做完了。全部测试过。只留下真正跑得起来的代码。
|
||||||
|
|
||||||
装上 oh-my-openagent。敲 `ultrawork`。就完事了。
|
装上 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
|
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 看的
|
### 给 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
|
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` 引用在应用每次修改前都会验证内容。外科手术级编辑,零陈旧行错误
|
- **基于哈希的编辑工具**: `LINE#ID` 引用在应用每次修改前都会验证内容。外科手术级编辑,零陈旧行错误
|
||||||
- **上下文注入**: 自动注入 AGENTS.md、README.md、条件规则
|
- **上下文注入**: 自动注入 AGENTS.md、README.md、条件规则
|
||||||
- **Claude Code 兼容**: 完整的 Hook 系统、命令、技能、Agents、MCP
|
- **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 等
|
- **效率功能**: Ralph Loop、Todo Enforcer、Comment Checker、Think Mode 等
|
||||||
- **Doctor 命令**: 内置诊断(`bunx oh-my-opencode doctor`),验证插件注册、配置、模型和环境
|
- **Doctor 命令**: 内置诊断(`bunx oh-my-opencode doctor`),验证插件注册、配置、模型和环境
|
||||||
@@ -368,7 +374,7 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动
|
|||||||
详细内容见 [Configuration Documentation](docs/reference/configuration.md)。
|
详细内容见 [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 支持**: 支持注释和尾逗号
|
- **JSONC 支持**: 支持注释和尾逗号
|
||||||
- **Agents**: 可对任意 Agent 覆盖模型、temperature、prompts 和权限
|
- **Agents**: 可对任意 Agent 覆盖模型、temperature、prompts 和权限
|
||||||
- **内置技能**: `playwright`(浏览器自动化)、`git-master`(原子提交)
|
- **内置技能**: `playwright`(浏览器自动化)、`git-master`(原子提交)
|
||||||
@@ -376,7 +382,7 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动
|
|||||||
- **后台任务**: 按 provider/model 配置并发上限
|
- **后台任务**: 按 provider/model 配置并发上限
|
||||||
- **类别**: 按领域的任务委托(`visual`、`business-logic`、自定义)
|
- **类别**: 按领域的任务委托(`visual`、`business-logic`、自定义)
|
||||||
- **Hooks**: 54+ 内置生命周期 Hook(启用 Team Mode 时为 61 个),都可以通过 `disabled_hooks` 控制
|
- **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 支持
|
- **LSP**: 包括重构工具的完整 LSP 支持
|
||||||
- **Experimental**: 激进截断、自动 resume 等
|
- **Experimental**: 激进截断、自动 resume 等
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"git-master",
|
"git-master",
|
||||||
"review-work",
|
"review-work",
|
||||||
"ai-slop-remover",
|
"ai-slop-remover",
|
||||||
|
"security-research",
|
||||||
|
"security-review",
|
||||||
"team-mode"
|
"team-mode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3567,6 +3567,14 @@
|
|||||||
"created_at": "2026-05-29T02:24:00Z",
|
"created_at": "2026-05-29T02:24:00Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 4594
|
"pullRequestNo": 4594
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fs2016l",
|
||||||
|
"id": 62631270,
|
||||||
|
"comment_id": 4578489173,
|
||||||
|
"created_at": "2026-05-29T18:20:07Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 4613
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 type { ConfigMergeResult } from "../types"
|
||||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||||
import { backupConfigFile } from "./backup-config"
|
import { backupConfigFile } from "./backup-config"
|
||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
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 { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||||
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
|
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<string>([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<ConfigMergeResult> {
|
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||||
try {
|
try {
|
||||||
ensureConfigDirectoryExists()
|
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)
|
const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
|
||||||
|
|
||||||
try {
|
let primaryResult: ConfigMergeResult | null = null
|
||||||
if (format === "none") {
|
for (const target of targets) {
|
||||||
const config: OpenCodeConfig = { plugin: [pluginEntry] }
|
const result = writePluginEntryToTarget({
|
||||||
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
|
target,
|
||||||
return { success: true, configPath: path }
|
currentVersion,
|
||||||
}
|
fallbackEntry: pluginEntry,
|
||||||
|
preferredSourceEntry,
|
||||||
|
})
|
||||||
|
|
||||||
const parseResult = parseOpenCodeConfigFileWithError(path)
|
if (!result.success) return result
|
||||||
if (!parseResult.config) {
|
if (target.primary) {
|
||||||
return {
|
primaryResult = result
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return primaryResult ?? { success: true, configPath: primaryTarget.path }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,4 +184,56 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
|||||||
expect(savedContent.includes('"plugin": [\n "oh-my-openagent"\n ]')).toBe(true)
|
expect(savedContent.includes('"plugin": [\n "oh-my-openagent"\n ]')).toBe(true)
|
||||||
expect(savedContent.includes("oh-my-opencode")).toBe(false)
|
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])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
|
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
|
||||||
|
|
||||||
describe("OhMyOpenCodeConfigSchema disabled_skills", () => {
|
describe("OhMyOpenCodeConfigSchema disabled_skills", () => {
|
||||||
test("accepts review-work and ai-slop-remover", () => {
|
test("accepts review-work, ai-slop-remover, and runtime security skills", () => {
|
||||||
// given
|
// given
|
||||||
const config = {
|
const config = {
|
||||||
disabled_skills: ["review-work", "ai-slop-remover"],
|
disabled_skills: [
|
||||||
|
"review-work",
|
||||||
|
"ai-slop-remover",
|
||||||
|
"security-research",
|
||||||
|
"security-review",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -17,6 +22,8 @@ describe("OhMyOpenCodeConfigSchema disabled_skills", () => {
|
|||||||
expect(result.data.disabled_skills).toEqual([
|
expect(result.data.disabled_skills).toEqual([
|
||||||
"review-work",
|
"review-work",
|
||||||
"ai-slop-remover",
|
"ai-slop-remover",
|
||||||
|
"security-research",
|
||||||
|
"security-review",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const BuiltinSkillNameSchema = z.enum([
|
|||||||
"git-master",
|
"git-master",
|
||||||
"review-work",
|
"review-work",
|
||||||
"ai-slop-remover",
|
"ai-slop-remover",
|
||||||
|
"security-research",
|
||||||
|
"security-review",
|
||||||
"team-mode",
|
"team-mode",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ export function createManagers(args: {
|
|||||||
tmuxConfig: TmuxConfig
|
tmuxConfig: TmuxConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
backgroundNotificationHookEnabled: boolean
|
backgroundNotificationHookEnabled: boolean
|
||||||
|
runtimeSkillSourceUrl?: string
|
||||||
deps?: Partial<CreateManagersDeps>
|
deps?: Partial<CreateManagersDeps>
|
||||||
}): Managers {
|
}): Managers {
|
||||||
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args
|
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled, runtimeSkillSourceUrl } = args
|
||||||
const deps = { ...defaultCreateManagersDeps, ...args.deps }
|
const deps = { ...defaultCreateManagersDeps, ...args.deps }
|
||||||
|
|
||||||
// Only mark the server as in-process when the SDK actually exposes a
|
// 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 },
|
ctx: { directory: ctx.directory, client: ctx.client },
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
runtimeSkillSourceUrl,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
tmuxSessionManager,
|
tmuxSessionManager,
|
||||||
|
|||||||
@@ -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`. |
|
| **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` |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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. |
|
| **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.
|
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 |
|
| 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 |
|
| dev-browser | 221 | — | Persistent page state browser |
|
||||||
| review-work | ~500 | — | 5-agent post-implementation review orchestrator |
|
| review-work | ~500 | — | 5-agent post-implementation review orchestrator |
|
||||||
| ai-slop-remover | ~300 | — | Remove AI code patterns |
|
| 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) |
|
| **team-mode** | — | — | Loaded only when `team_mode.enabled` (skill explains the 12 tools to agents) |
|
||||||
| frontend-ui-ux | 79 | — | Design-first UI development |
|
| frontend-ui-ux | 79 | — | Design-first UI development |
|
||||||
| (git-master-skill-metadata) | — | — | Companion to git-master |
|
| (git-master-skill-metadata) | — | — | Companion to git-master |
|
||||||
|
|||||||
@@ -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
|
**Generated:** 2026-05-15
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Skills shipped inside the plugin (always available, no install). Registered via
|
|||||||
```
|
```
|
||||||
builtin-skills/
|
builtin-skills/
|
||||||
├── index.ts # Barrel exports
|
├── index.ts # Barrel exports
|
||||||
├── skills.ts # createBuiltinSkills() factory — registers all 10 below
|
├── skills.ts # createBuiltinSkills() factory — registers all 12 below
|
||||||
├── types.ts # BuiltinSkill interface
|
├── types.ts # BuiltinSkill interface
|
||||||
├── skills/
|
├── skills/
|
||||||
│ ├── git-master.ts # 1111 LOC
|
│ ├── git-master.ts # 1111 LOC
|
||||||
@@ -23,12 +23,15 @@ builtin-skills/
|
|||||||
│ ├── review-work.ts # 5-agent post-implementation review
|
│ ├── review-work.ts # 5-agent post-implementation review
|
||||||
│ ├── ai-slop-remover.ts # Remove AI-generated code patterns
|
│ ├── ai-slop-remover.ts # Remove AI-generated code patterns
|
||||||
│ ├── team-mode.ts # 12 team_* tool documentation (gated)
|
│ ├── 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
|
│ ├── git-master-sections/ # Git-master prompt sub-sections
|
||||||
│ └── index.ts # skill barrel
|
│ └── index.ts # skill barrel
|
||||||
├── git-master/ # Resources for git-master skill
|
├── git-master/ # Resources for git-master skill
|
||||||
├── frontend-ui-ux/ # Resources for frontend-ui-ux skill
|
├── frontend-ui-ux/ # Resources for frontend-ui-ux skill
|
||||||
├── agent-browser/ # Resources for agent-browser variant
|
├── 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
|
## SKILL CATALOG
|
||||||
@@ -43,6 +46,8 @@ builtin-skills/
|
|||||||
| `frontend-ui-ux` | 79 | — | Design-first UI development guidance |
|
| `frontend-ui-ux` | 79 | — | Design-first UI development guidance |
|
||||||
| `review-work` | ~500 | — | Post-implementation review orchestrator (5 parallel agents) |
|
| `review-work` | ~500 | — | Post-implementation review orchestrator (5 parallel agents) |
|
||||||
| `ai-slop-remover` | ~300 | — | Remove AI-generated code smells |
|
| `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 |
|
| `team-mode` | — | — | **Conditional** — only loaded when `team_mode.enabled`; documents the 12 `team_*` tools and lifecycle |
|
||||||
|
|
||||||
## BROWSER VARIANT SELECTION
|
## BROWSER VARIANT SELECTION
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { createBuiltinSkills } from "./skills"
|
import { createBuiltinSkills } from "./skills"
|
||||||
|
|
||||||
@@ -11,8 +13,8 @@ describe("createBuiltinSkills", () => {
|
|||||||
// then
|
// then
|
||||||
const browserSkill = skills.find((s) => s.name === "playwright")
|
const browserSkill = skills.find((s) => s.name === "playwright")
|
||||||
expect(browserSkill).toBeDefined()
|
expect(browserSkill).toBeDefined()
|
||||||
expect(browserSkill!.description).toContain("browser")
|
expect(browserSkill?.description).toContain("browser")
|
||||||
expect(browserSkill!.mcpConfig?.playwright).toBeDefined()
|
expect(browserSkill?.mcpConfig?.playwright).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns playwright skill when browserProvider is 'playwright'", () => {
|
test("returns playwright skill when browserProvider is 'playwright'", () => {
|
||||||
@@ -44,7 +46,7 @@ describe("createBuiltinSkills", () => {
|
|||||||
const playwrightSkill = skills.find((skill) => skill.name === "playwright")
|
const playwrightSkill = skills.find((skill) => skill.name === "playwright")
|
||||||
const agentBrowserSkill = skills.find((skill) => skill.name === "agent-browser")
|
const agentBrowserSkill = skills.find((skill) => skill.name === "agent-browser")
|
||||||
expect(devBrowserSkill).toBeDefined()
|
expect(devBrowserSkill).toBeDefined()
|
||||||
expect(devBrowserSkill!.description).toContain("Browser automation")
|
expect(devBrowserSkill?.description).toContain("Browser automation")
|
||||||
expect(playwrightSkill).toBeUndefined()
|
expect(playwrightSkill).toBeUndefined()
|
||||||
expect(agentBrowserSkill).toBeUndefined()
|
expect(agentBrowserSkill).toBeUndefined()
|
||||||
expect(skillNames).not.toContain("playwright-cli")
|
expect(skillNames).not.toContain("playwright-cli")
|
||||||
@@ -62,9 +64,9 @@ describe("createBuiltinSkills", () => {
|
|||||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||||
const playwrightSkill = skills.find((s) => s.name === "playwright")
|
const playwrightSkill = skills.find((s) => s.name === "playwright")
|
||||||
expect(agentBrowserSkill).toBeDefined()
|
expect(agentBrowserSkill).toBeDefined()
|
||||||
expect(agentBrowserSkill!.description).toContain("browser")
|
expect(agentBrowserSkill?.description).toContain("browser")
|
||||||
expect(agentBrowserSkill!.allowedTools).toContain("Bash(agent-browser:*)")
|
expect(agentBrowserSkill?.allowedTools).toContain("Bash(agent-browser:*)")
|
||||||
expect(agentBrowserSkill!.template).toContain("agent-browser")
|
expect(agentBrowserSkill?.template).toContain("agent-browser")
|
||||||
expect(playwrightSkill).toBeUndefined()
|
expect(playwrightSkill).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,13 +79,13 @@ describe("createBuiltinSkills", () => {
|
|||||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||||
|
|
||||||
// then - template should contain substantial content (inlined, not fallback)
|
// then - template should contain substantial content (inlined, not fallback)
|
||||||
expect(agentBrowserSkill!.template).toContain("## Quick start")
|
expect(agentBrowserSkill?.template).toContain("## Quick start")
|
||||||
expect(agentBrowserSkill!.template).toContain("## Commands")
|
expect(agentBrowserSkill?.template).toContain("## Commands")
|
||||||
expect(agentBrowserSkill!.template).toContain("agent-browser open")
|
expect(agentBrowserSkill?.template).toContain("agent-browser open")
|
||||||
expect(agentBrowserSkill!.template).toContain("agent-browser snapshot")
|
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
|
// given - both provider options
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -97,10 +99,12 @@ describe("createBuiltinSkills", () => {
|
|||||||
expect(skills.find((s) => s.name === "git-master")).toBeDefined()
|
expect(skills.find((s) => s.name === "git-master")).toBeDefined()
|
||||||
expect(skills.find((s) => s.name === "review-work")).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 === "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
|
// given
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -109,9 +113,9 @@ describe("createBuiltinSkills", () => {
|
|||||||
const devBrowserSkills = createBuiltinSkills({ browserProvider: "dev-browser" })
|
const devBrowserSkills = createBuiltinSkills({ browserProvider: "dev-browser" })
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(defaultSkills).toHaveLength(5)
|
expect(defaultSkills).toHaveLength(7)
|
||||||
expect(agentBrowserSkills).toHaveLength(5)
|
expect(agentBrowserSkills).toHaveLength(7)
|
||||||
expect(devBrowserSkills).toHaveLength(5)
|
expect(devBrowserSkills).toHaveLength(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should exclude playwright when it is in disabledSkills", () => {
|
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)).not.toContain("dev-browser")
|
||||||
expect(skills.map((s) => s.name)).toContain("review-work")
|
expect(skills.map((s) => s.name)).toContain("review-work")
|
||||||
expect(skills.map((s) => s.name)).toContain("ai-slop-remover")
|
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", () => {
|
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)).not.toContain("dev-browser")
|
||||||
expect(skills.map((s) => s.name)).toContain("review-work")
|
expect(skills.map((s) => s.name)).toContain("review-work")
|
||||||
expect(skills.map((s) => s.name)).toContain("ai-slop-remover")
|
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", () => {
|
test("should return an empty array when all skills are disabled", () => {
|
||||||
// #given
|
// #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
|
// #when
|
||||||
const skills = createBuiltinSkills(options)
|
const skills = createBuiltinSkills(options)
|
||||||
@@ -167,7 +175,7 @@ describe("createBuiltinSkills", () => {
|
|||||||
const skills = createBuiltinSkills(options)
|
const skills = createBuiltinSkills(options)
|
||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(skills.length).toBe(5)
|
expect(skills.length).toBe(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review-work skill has correct structure", () => {
|
test("review-work skill has correct structure", () => {
|
||||||
@@ -179,13 +187,13 @@ describe("createBuiltinSkills", () => {
|
|||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(reviewWork).toBeDefined()
|
expect(reviewWork).toBeDefined()
|
||||||
expect(reviewWork!.description).toContain("review")
|
expect(reviewWork?.description).toContain("review")
|
||||||
expect(reviewWork!.template).toContain("5-Agent Parallel Review Orchestrator")
|
expect(reviewWork?.template).toContain("5-Agent Parallel Review Orchestrator")
|
||||||
expect(reviewWork!.template).toContain("Goal & Constraint Verification")
|
expect(reviewWork?.template).toContain("Goal & Constraint Verification")
|
||||||
expect(reviewWork!.template).toContain("QA")
|
expect(reviewWork?.template).toContain("QA")
|
||||||
expect(reviewWork!.template).toContain("Code Quality")
|
expect(reviewWork?.template).toContain("Code Quality")
|
||||||
expect(reviewWork!.template).toContain("Security")
|
expect(reviewWork?.template).toContain("Security")
|
||||||
expect(reviewWork!.template).toContain("Context Mining")
|
expect(reviewWork?.template).toContain("Context Mining")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review-work skill explains Codex tool compatibility before OpenCode orchestration examples", () => {
|
test("review-work skill explains Codex tool compatibility before OpenCode orchestration examples", () => {
|
||||||
@@ -194,8 +202,9 @@ describe("createBuiltinSkills", () => {
|
|||||||
|
|
||||||
// #when
|
// #when
|
||||||
const reviewWork = skills.find((s) => s.name === "review-work")
|
const reviewWork = skills.find((s) => s.name === "review-work")
|
||||||
const compatibilityIndex = reviewWork!.template.indexOf("## Codex Harness Tool Compatibility")
|
const reviewWorkTemplate = reviewWork?.template ?? ""
|
||||||
const opencodeExampleIndex = reviewWork!.template.search(/\b(?:background_output|team_[a-z_]+|task)\s*\(/)
|
const compatibilityIndex = reviewWorkTemplate.indexOf("## Codex Harness Tool Compatibility")
|
||||||
|
const opencodeExampleIndex = reviewWorkTemplate.search(/\b(?:background_output|team_[a-z_]+|task)\s*\(/)
|
||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(compatibilityIndex >= 0).toBe(true)
|
expect(compatibilityIndex >= 0).toBe(true)
|
||||||
@@ -211,9 +220,37 @@ describe("createBuiltinSkills", () => {
|
|||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(aiSlopRemover).toBeDefined()
|
expect(aiSlopRemover).toBeDefined()
|
||||||
expect(aiSlopRemover!.description).toContain("AI-generated code smells")
|
expect(aiSlopRemover?.description).toContain("AI-generated code smells")
|
||||||
expect(aiSlopRemover!.template).toContain("DETECTION CRITERIA")
|
expect(aiSlopRemover?.template).toContain("DETECTION CRITERIA")
|
||||||
expect(aiSlopRemover!.template).toContain("SAFETY RULES")
|
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'", () => {
|
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 playwrightSkill = skills.find((s) => s.name === "playwright")
|
||||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||||
expect(playwrightSkill).toBeDefined()
|
expect(playwrightSkill).toBeDefined()
|
||||||
expect(playwrightSkill!.description).toContain("browser")
|
expect(playwrightSkill?.description).toContain("browser")
|
||||||
expect(playwrightSkill!.allowedTools).toContain("Bash(playwright-cli:*)")
|
expect(playwrightSkill?.allowedTools).toContain("Bash(playwright-cli:*)")
|
||||||
expect(playwrightSkill!.mcpConfig).toBeUndefined()
|
expect(playwrightSkill?.mcpConfig).toBeUndefined()
|
||||||
expect(agentBrowserSkill).toBeUndefined()
|
expect(agentBrowserSkill).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,8 +279,8 @@ describe("createBuiltinSkills", () => {
|
|||||||
const skill = skills.find((s) => s.name === "playwright")
|
const skill = skills.find((s) => s.name === "playwright")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(skill!.template).toContain("playwright-cli open")
|
expect(skill?.template).toContain("playwright-cli open")
|
||||||
expect(skill!.template).toContain("playwright-cli snapshot")
|
expect(skill?.template).toContain("playwright-cli snapshot")
|
||||||
expect(skill!.template).toContain("playwright-cli click")
|
expect(skill?.template).toContain("playwright-cli click")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
devBrowserSkill,
|
devBrowserSkill,
|
||||||
reviewWorkSkill,
|
reviewWorkSkill,
|
||||||
aiSlopRemoverSkill,
|
aiSlopRemoverSkill,
|
||||||
|
securityResearchSkill,
|
||||||
|
securityReviewSkill,
|
||||||
teamModeSkill,
|
teamModeSkill,
|
||||||
} from "./skills/index"
|
} from "./skills/index"
|
||||||
|
|
||||||
@@ -33,7 +35,15 @@ export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): B
|
|||||||
browserSkill = playwrightSkill
|
browserSkill = playwrightSkill
|
||||||
}
|
}
|
||||||
|
|
||||||
const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, reviewWorkSkill, aiSlopRemoverSkill]
|
const skills = [
|
||||||
|
browserSkill,
|
||||||
|
frontendUiUxSkill,
|
||||||
|
gitMasterSkill,
|
||||||
|
reviewWorkSkill,
|
||||||
|
aiSlopRemoverSkill,
|
||||||
|
securityResearchSkill,
|
||||||
|
securityReviewSkill,
|
||||||
|
]
|
||||||
|
|
||||||
if (teamModeEnabled && !disabledSkills?.has("team-mode")) {
|
if (teamModeEnabled && !disabledSkills?.has("team-mode")) {
|
||||||
skills.push(teamModeSkill)
|
skills.push(teamModeSkill)
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export { gitMasterSkill } from "./git-master"
|
|||||||
export { devBrowserSkill } from "./dev-browser"
|
export { devBrowserSkill } from "./dev-browser"
|
||||||
export { reviewWorkSkill } from "./review-work"
|
export { reviewWorkSkill } from "./review-work"
|
||||||
export { aiSlopRemoverSkill } from "./ai-slop-remover"
|
export { aiSlopRemoverSkill } from "./ai-slop-remover"
|
||||||
|
export { securityResearchSkill } from "./security-research"
|
||||||
|
export { securityReviewSkill } from "./security-review"
|
||||||
export * from "./team-mode"
|
export * from "./team-mode"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
applyRuntimeSkillSourceConfig,
|
||||||
|
selectRuntimeSecuritySkills,
|
||||||
|
type OpenCodeSkillHostConfig,
|
||||||
|
type RuntimeSkillSourceEntry,
|
||||||
|
} from "./runtime-skill-config"
|
||||||
|
export { createRuntimeSkillSourceServer, type RuntimeSkillSourceServer } from "./source-server"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import {
|
||||||
|
applyRuntimeSkillSourceConfig,
|
||||||
|
selectRuntimeSecuritySkills,
|
||||||
|
type OpenCodeSkillHostConfig,
|
||||||
|
} from "./runtime-skill-config"
|
||||||
|
|
||||||
|
type DisabledSkillName = NonNullable<OhMyOpenCodeConfig["disabled_skills"]>[number]
|
||||||
|
|
||||||
|
function createPluginConfig(disabledSkills?: readonly DisabledSkillName[]): OhMyOpenCodeConfig {
|
||||||
|
return {
|
||||||
|
git_master: {
|
||||||
|
commit_footer: true,
|
||||||
|
include_co_authored_by: true,
|
||||||
|
git_env_prefix: "GIT_MASTER=1",
|
||||||
|
},
|
||||||
|
disabled_skills: disabledSkills ? [...disabledSkills] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpenCode runtime skill source config", () => {
|
||||||
|
test("adds the runtime source URL while preserving existing skill URLs and paths", () => {
|
||||||
|
// given
|
||||||
|
const config: OpenCodeSkillHostConfig = {
|
||||||
|
skills: {
|
||||||
|
urls: ["https://example.com/skills"],
|
||||||
|
paths: ["/keep/user/path"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
applyRuntimeSkillSourceConfig({
|
||||||
|
config,
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
sourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills?.urls).toEqual([
|
||||||
|
"https://example.com/skills",
|
||||||
|
"http://127.0.0.1:49152/",
|
||||||
|
])
|
||||||
|
expect(config.skills?.paths).toEqual(["/keep/user/path"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates the runtime source URL", () => {
|
||||||
|
// given
|
||||||
|
const config: OpenCodeSkillHostConfig = {
|
||||||
|
skills: {
|
||||||
|
urls: ["http://127.0.0.1:49152/"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
applyRuntimeSkillSourceConfig({
|
||||||
|
config,
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
sourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills?.urls).toEqual(["http://127.0.0.1:49152/"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not create skills config when every runtime security skill is disabled", () => {
|
||||||
|
// given
|
||||||
|
const config: OpenCodeSkillHostConfig = {}
|
||||||
|
|
||||||
|
// when
|
||||||
|
applyRuntimeSkillSourceConfig({
|
||||||
|
config,
|
||||||
|
pluginConfig: createPluginConfig(["security-research", "security-review"]),
|
||||||
|
sourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("security-research disablement keeps security-review enabled", () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig = createPluginConfig(["security-research"])
|
||||||
|
|
||||||
|
// when
|
||||||
|
const skills = selectRuntimeSecuritySkills(pluginConfig)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(skills.map((skill) => skill.name)).toEqual(["security-review"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("security-review disablement suppresses only the review alias", () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig = createPluginConfig(["security-review"])
|
||||||
|
|
||||||
|
// when
|
||||||
|
const skills = selectRuntimeSecuritySkills(pluginConfig)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(skills.map((skill) => skill.name)).toEqual(["security-research"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import { securityResearchSkill, securityReviewSkill } from "../builtin-skills/skills/index"
|
||||||
|
import { createOpenCodeSkillMarkdown, type OpenCodeSkillMarkdown } from "./skill-markdown"
|
||||||
|
|
||||||
|
export type RuntimeSkillSourceEntry = OpenCodeSkillMarkdown
|
||||||
|
|
||||||
|
export type OpenCodeSkillsHostConfig = {
|
||||||
|
readonly paths?: readonly string[]
|
||||||
|
readonly urls?: readonly string[]
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenCodeSkillHostConfig = Record<string, unknown> & {
|
||||||
|
skills?: OpenCodeSkillsHostConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item): item is string => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUnique(values: readonly string[], next: string): string[] {
|
||||||
|
if (values.includes(next)) return [...values]
|
||||||
|
return [...values, next]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRuntimeSecuritySkills(
|
||||||
|
pluginConfig: Pick<OhMyOpenCodeConfig, "disabled_skills"> = {},
|
||||||
|
): RuntimeSkillSourceEntry[] {
|
||||||
|
const disabledSkills = new Set(pluginConfig.disabled_skills ?? [])
|
||||||
|
const includeResearch = !disabledSkills.has("security-research")
|
||||||
|
const includeReview = !disabledSkills.has("security-review")
|
||||||
|
if (!includeResearch && !includeReview) return []
|
||||||
|
|
||||||
|
const skills = []
|
||||||
|
if (includeResearch) {
|
||||||
|
skills.push(securityResearchSkill)
|
||||||
|
}
|
||||||
|
if (includeReview) {
|
||||||
|
skills.push(securityReviewSkill)
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills.map((skill) => createOpenCodeSkillMarkdown(skill))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRuntimeSkillSourceConfig(params: {
|
||||||
|
readonly config: OpenCodeSkillHostConfig
|
||||||
|
readonly pluginConfig: Pick<OhMyOpenCodeConfig, "disabled_skills">
|
||||||
|
readonly sourceUrl: string
|
||||||
|
}): void {
|
||||||
|
if (selectRuntimeSecuritySkills(params.pluginConfig).length === 0) return
|
||||||
|
|
||||||
|
const existingSkills = isRecord(params.config.skills) ? params.config.skills : {}
|
||||||
|
const existingUrls = toStringList(existingSkills.urls)
|
||||||
|
const nextUrls = appendUnique(existingUrls, params.sourceUrl)
|
||||||
|
|
||||||
|
params.config.skills = {
|
||||||
|
...existingSkills,
|
||||||
|
urls: nextUrls,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { BuiltinSkill } from "../builtin-skills/types"
|
||||||
|
|
||||||
|
export type OpenCodeSkillMarkdown = {
|
||||||
|
readonly name: string
|
||||||
|
readonly description: string
|
||||||
|
readonly markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpenCodeSkillMarkdown(skill: BuiltinSkill): OpenCodeSkillMarkdown {
|
||||||
|
const body = skill.template.trimStart()
|
||||||
|
const markdown = [
|
||||||
|
"---",
|
||||||
|
`name: ${skill.name}`,
|
||||||
|
`description: ${JSON.stringify(skill.description)}`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
markdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { createRuntimeSkillSourceServer } from "./source-server"
|
||||||
|
import { selectRuntimeSecuritySkills } from "./runtime-skill-config"
|
||||||
|
|
||||||
|
let cleanupServer: { readonly stop: () => void } | undefined
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupServer?.stop()
|
||||||
|
cleanupServer = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("runtime security skill source server", () => {
|
||||||
|
test("serves an OpenCode skill index and markdown files with matching frontmatter names", async () => {
|
||||||
|
// given
|
||||||
|
const source = createRuntimeSkillSourceServer({
|
||||||
|
skills: selectRuntimeSecuritySkills(),
|
||||||
|
})
|
||||||
|
cleanupServer = source
|
||||||
|
|
||||||
|
// when
|
||||||
|
const indexResponse = await fetch(new URL("index.json", source.url))
|
||||||
|
const index = await indexResponse.json()
|
||||||
|
const researchResponse = await fetch(new URL("security-research/SKILL.md", source.url))
|
||||||
|
const reviewResponse = await fetch(new URL("security-review/SKILL.md", source.url))
|
||||||
|
const researchMarkdown = await researchResponse.text()
|
||||||
|
const reviewMarkdown = await reviewResponse.text()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(indexResponse.status).toBe(200)
|
||||||
|
expect(index).toEqual({
|
||||||
|
skills: [
|
||||||
|
{ name: "security-research", files: ["SKILL.md"] },
|
||||||
|
{ name: "security-review", files: ["SKILL.md"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(researchResponse.status).toBe(200)
|
||||||
|
expect(reviewResponse.status).toBe(200)
|
||||||
|
expect(researchMarkdown).toStartWith("---\nname: security-research\n")
|
||||||
|
expect(reviewMarkdown).toStartWith("---\nname: security-review\n")
|
||||||
|
expect(researchMarkdown).toContain("Security Research - Team Mode Vulnerability Audit")
|
||||||
|
expect(reviewMarkdown).toContain("Security Research - Team Mode Vulnerability Audit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 for unknown paths", async () => {
|
||||||
|
// given
|
||||||
|
const source = createRuntimeSkillSourceServer({
|
||||||
|
skills: selectRuntimeSecuritySkills(),
|
||||||
|
})
|
||||||
|
cleanupServer = source
|
||||||
|
|
||||||
|
// when
|
||||||
|
const response = await fetch(new URL("missing/SKILL.md", source.url))
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { RuntimeSkillSourceEntry } from "./runtime-skill-config"
|
||||||
|
|
||||||
|
export type RuntimeSkillSourceServer = {
|
||||||
|
readonly url: string
|
||||||
|
readonly stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return Response.json(body, {
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownResponse(markdown: string): Response {
|
||||||
|
return new Response(markdown, {
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
"content-type": "text/markdown; charset=utf-8",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeSkillSourceServer(options: {
|
||||||
|
readonly skills: readonly RuntimeSkillSourceEntry[]
|
||||||
|
}): RuntimeSkillSourceServer {
|
||||||
|
const skillMarkdownByPath = new Map(
|
||||||
|
options.skills.map((skill) => [`/${skill.name}/SKILL.md`, skill.markdown]),
|
||||||
|
)
|
||||||
|
const index = {
|
||||||
|
skills: options.skills.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
files: ["SKILL.md"],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/" || url.pathname === "/index.json") {
|
||||||
|
return jsonResponse(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = skillMarkdownByPath.get(url.pathname)
|
||||||
|
if (markdown) return markdownResponse(markdown)
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: server.url.toString(),
|
||||||
|
stop: () => server.stop(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,17 @@ describe("detectErrorType", () => {
|
|||||||
expect(result).toBe("thinking_block_modified")
|
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", () => {
|
it("#given an unrecognized error #when detecting #then returns null", () => {
|
||||||
//#given
|
//#given
|
||||||
const error = { message: "some random error" }
|
const error = { message: "some random error" }
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
|
|||||||
return "assistant_prefill_unsupported"
|
return "assistant_prefill_unsupported"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.includes("thinking") && message.includes("cannot be modified")) {
|
||||||
|
return "thinking_block_modified"
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
message.includes("thinking") &&
|
message.includes("thinking") &&
|
||||||
(message.includes("first block") ||
|
(message.includes("first block") ||
|
||||||
@@ -78,11 +82,6 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
|
|||||||
return "thinking_block_order"
|
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")) {
|
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||||
return "thinking_disabled_violation"
|
return "thinking_disabled_violation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,30 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
|||||||
let shouldKeepProcessingError = false
|
let shouldKeepProcessingError = false
|
||||||
|
|
||||||
try {
|
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) {
|
if (onAbortCallback) {
|
||||||
onAbortCallback(sessionID)
|
onAbortCallback(sessionID)
|
||||||
}
|
}
|
||||||
@@ -224,7 +248,7 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
|||||||
unavailable_tool: "Recovering from unavailable tool call...",
|
unavailable_tool: "Recovering from unavailable tool call...",
|
||||||
thinking_block_order: "Fixing message structure...",
|
thinking_block_order: "Fixing message structure...",
|
||||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
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.",
|
"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)
|
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||||
await resumeSession(ctx.client, resumeConfig)
|
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") {
|
} else if (errorType === "assistant_prefill_unsupported") {
|
||||||
shouldKeepProcessingError = true
|
shouldKeepProcessingError = true
|
||||||
success = false
|
success = false
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
|||||||
import type { MessageData, StoredPart } from "../types"
|
import type { MessageData, StoredPart } from "../types"
|
||||||
import { readMessages } from "./messages-reader"
|
import { readMessages } from "./messages-reader"
|
||||||
import { readParts } from "./parts-reader"
|
import { readParts } from "./parts-reader"
|
||||||
import { log, isSqliteBackend, patchPart } from "../../../shared"
|
import { log, isSqliteBackend, normalizeSDKResponse, patchPart } from "../../../shared"
|
||||||
import { normalizeSDKResponse } from "../../../shared"
|
import { isLatestAssistantMessage, isLatestAssistantMessageFromSDK } from "./latest-assistant-message"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
type StoredSignedThinkingPart = StoredPart & {
|
type StoredSignedThinkingPart = StoredPart & {
|
||||||
@@ -28,6 +28,8 @@ type ThinkingPrependDeps = {
|
|||||||
findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK
|
findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK
|
||||||
readTargetPartIDs: typeof readTargetPartIDs
|
readTargetPartIDs: typeof readTargetPartIDs
|
||||||
readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK
|
readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK
|
||||||
|
isLatestAssistantMessage?: typeof isLatestAssistantMessage
|
||||||
|
isLatestAssistantMessageFromSDK?: typeof isLatestAssistantMessageFromSDK
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingPrependDeps: ThinkingPrependDeps = {
|
const thinkingPrependDeps: ThinkingPrependDeps = {
|
||||||
@@ -38,6 +40,8 @@ const thinkingPrependDeps: ThinkingPrependDeps = {
|
|||||||
findLastThinkingPartFromSDK,
|
findLastThinkingPartFromSDK,
|
||||||
readTargetPartIDs,
|
readTargetPartIDs,
|
||||||
readTargetPartIDsFromSDK,
|
readTargetPartIDsFromSDK,
|
||||||
|
isLatestAssistantMessage,
|
||||||
|
isLatestAssistantMessageFromSDK,
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTargetPartIDs(messageID: string): string[] {
|
function readTargetPartIDs(messageID: string): string[] {
|
||||||
@@ -137,6 +141,14 @@ export function prependThinkingPart(
|
|||||||
return false
|
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)
|
const previousThinkingPart = deps.findLastThinkingPart(sessionID, messageID)
|
||||||
if (!previousThinkingPart) {
|
if (!previousThinkingPart) {
|
||||||
return false
|
return false
|
||||||
@@ -198,6 +210,17 @@ export async function prependThinkingPartAsync(
|
|||||||
messageID: string,
|
messageID: string,
|
||||||
deps: ThinkingPrependDeps = thinkingPrependDeps
|
deps: ThinkingPrependDeps = thinkingPrependDeps
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
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)
|
const previousThinkingPart = await deps.findLastThinkingPartFromSDK(client, sessionID, messageID)
|
||||||
if (!previousThinkingPart) {
|
if (!previousThinkingPart) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { createSessionRecoveryHook } from "./hook"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
|
||||||
|
|
||||||
|
type RecoverableInfo = Parameters<ReturnType<typeof createSessionRecoveryHook>["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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
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: <TData>(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,7 +33,7 @@ async function runTransform(messages: TestMessage[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createThinkingBlockValidatorHook", () => {
|
describe("createThinkingBlockValidatorHook", () => {
|
||||||
it("injects signed thinking history verbatim", async () => {
|
it("does not copy signed thinking history into a later assistant message", async () => {
|
||||||
//#given
|
//#given
|
||||||
const signedThinkingPart: TestPart = {
|
const signedThinkingPart: TestPart = {
|
||||||
type: "thinking",
|
type: "thinking",
|
||||||
@@ -55,10 +55,10 @@ describe("createThinkingBlockValidatorHook", () => {
|
|||||||
await runTransform(messages)
|
await runTransform(messages)
|
||||||
|
|
||||||
//#then
|
//#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
|
//#given
|
||||||
const signedRedactedThinkingPart: TestPart = {
|
const signedRedactedThinkingPart: TestPart = {
|
||||||
type: "redacted_thinking",
|
type: "redacted_thinking",
|
||||||
@@ -79,7 +79,7 @@ describe("createThinkingBlockValidatorHook", () => {
|
|||||||
await runTransform(messages)
|
await runTransform(messages)
|
||||||
|
|
||||||
//#then
|
//#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 () => {
|
it("skips hook when history contains reasoning only", async () => {
|
||||||
|
|||||||
@@ -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"
|
import type { Message, Part } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
interface MessageWithParts {
|
interface MessageWithParts {
|
||||||
@@ -28,155 +12,10 @@ type MessagesTransformHook = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||||
return {
|
return {
|
||||||
"experimental.chat.messages.transform": async (_input, output) => {
|
"experimental.chat.messages.transform": async () => {
|
||||||
const { messages } = output
|
return
|
||||||
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,93 @@ describe("MCP env allowlist initialization", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("runtime security skill source registration", () => {
|
||||||
|
test("adds the runtime skill source URL to the live OpenCode config", async () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig = createPluginConfig({})
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-7",
|
||||||
|
agent: {},
|
||||||
|
skills: {
|
||||||
|
urls: ["https://example.com/skills"],
|
||||||
|
paths: ["/tmp/user-skills"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills).toMatchObject({
|
||||||
|
urls: ["https://example.com/skills", "http://127.0.0.1:49152/"],
|
||||||
|
paths: ["/tmp/user-skills"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds the runtime skill source when only security-review remains enabled", async () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig = createPluginConfig({
|
||||||
|
disabled_skills: ["security-research"],
|
||||||
|
})
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-7",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills).toMatchObject({
|
||||||
|
urls: ["http://127.0.0.1:49152/"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not add a runtime skill source when both security skills are disabled", async () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig = createPluginConfig({
|
||||||
|
disabled_skills: ["security-research", "security-review"],
|
||||||
|
})
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-7",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
runtimeSkillSourceUrl: "http://127.0.0.1:49152/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.skills).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("Plan agent demote behavior", () => {
|
describe("Plan agent demote behavior", () => {
|
||||||
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
|
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
|
||||||
// #given
|
// #given
|
||||||
@@ -1002,10 +1089,11 @@ describe("Prometheus direct override priority over category", () => {
|
|||||||
// #then - prompt_append is appended to base prompt, not overwriting it
|
// #then - prompt_append is appended to base prompt, not overwriting it
|
||||||
const agents = config.agent as Record<string, { prompt?: string }>
|
const agents = config.agent as Record<string, { prompt?: string }>
|
||||||
const pKey = getAgentListDisplayName("prometheus")
|
const pKey = getAgentListDisplayName("prometheus")
|
||||||
|
const prometheusPrompt = agents[pKey]?.prompt
|
||||||
expect(agents[pKey]).toBeDefined()
|
expect(agents[pKey]).toBeDefined()
|
||||||
expect(agents[pKey].prompt).toContain("Prometheus")
|
expect(prometheusPrompt).toContain("Prometheus")
|
||||||
expect(agents[pKey].prompt).toContain(customInstructions)
|
expect(prometheusPrompt).toContain(customInstructions)
|
||||||
expect(agents[pKey].prompt!.endsWith(customInstructions)).toBe(true)
|
expect(prometheusPrompt?.endsWith(customInstructions)).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OhMyOpenCodeConfig } from "../config";
|
import type { OhMyOpenCodeConfig } from "../config";
|
||||||
|
import { applyRuntimeSkillSourceConfig } from "../features/opencode-runtime-skills"
|
||||||
import { setAdditionalAllowedMcpEnvVars } from "../features/claude-code-mcp-loader";
|
import { setAdditionalAllowedMcpEnvVars } from "../features/claude-code-mcp-loader";
|
||||||
import type { ModelCacheState } from "../plugin-state";
|
import type { ModelCacheState } from "../plugin-state";
|
||||||
import { log } from "../shared";
|
import { log } from "../shared";
|
||||||
@@ -26,13 +27,14 @@ function collectTrustedVisionCapableModels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigHandlerDeps {
|
export interface ConfigHandlerDeps {
|
||||||
ctx: { directory: string; client?: any };
|
ctx: { directory: string; client?: unknown };
|
||||||
pluginConfig: OhMyOpenCodeConfig;
|
pluginConfig: OhMyOpenCodeConfig;
|
||||||
modelCacheState: ModelCacheState;
|
modelCacheState: ModelCacheState;
|
||||||
|
runtimeSkillSourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createConfigHandler(deps: ConfigHandlerDeps) {
|
export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||||
const { ctx, pluginConfig, modelCacheState } = deps;
|
const { ctx, pluginConfig, modelCacheState, runtimeSkillSourceUrl } = deps;
|
||||||
|
|
||||||
return async (config: Record<string, unknown>) => {
|
return async (config: Record<string, unknown>) => {
|
||||||
const formatterConfig = config.formatter;
|
const formatterConfig = config.formatter;
|
||||||
@@ -59,6 +61,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
applyToolConfig({ config, pluginConfig, agentResult });
|
applyToolConfig({ config, pluginConfig, agentResult });
|
||||||
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
|
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||||
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||||
|
if (runtimeSkillSourceUrl) {
|
||||||
|
applyRuntimeSkillSourceConfig({
|
||||||
|
config,
|
||||||
|
pluginConfig,
|
||||||
|
sourceUrl: runtimeSkillSourceUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
config.formatter = formatterConfig;
|
config.formatter = formatterConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -97,4 +97,53 @@ describe("messages transform thinking block integration", () => {
|
|||||||
expect(resumedMessage?.parts[1]).toBe(thinkingAfterAnswer)
|
expect(resumedMessage?.parts[1]).toBe(thinkingAfterAnswer)
|
||||||
expect(countThinkingParts(resumedMessage?.parts ?? [])).toBe(1)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,75 @@ describe("createSkillContext", () => {
|
|||||||
rmSync(testDirectory, { recursive: true, force: true })
|
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<string>())
|
||||||
|
|
||||||
|
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 () => {
|
it("excludes discovered playwright skill when browser provider is agent-browser", async () => {
|
||||||
// given
|
// given
|
||||||
const discoveredPlaywrightDir = join(testDirectory, ".claude", "skills", "playwright")
|
const discoveredPlaywrightDir = join(testDirectory, ".claude", "skills", "playwright")
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ const mockCreateManagers = mock(() => ({
|
|||||||
skillMcpManager: { disconnectAll: async () => {} },
|
skillMcpManager: { disconnectAll: async () => {} },
|
||||||
configHandler: 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 () => ({
|
const mockCreateTools = mock(async () => ({
|
||||||
mergedSkills: [],
|
mergedSkills: [],
|
||||||
availableSkills: [],
|
availableSkills: [],
|
||||||
@@ -71,6 +78,7 @@ function createTestPluginModule(): ReturnType<typeof createPluginModule> {
|
|||||||
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never,
|
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never,
|
||||||
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never,
|
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never,
|
||||||
createManagers: mockCreateManagers as never,
|
createManagers: mockCreateManagers as never,
|
||||||
|
createRuntimeSkillSourceServer: mockCreateRuntimeSkillSourceServer as never,
|
||||||
createTools: mockCreateTools as never,
|
createTools: mockCreateTools as never,
|
||||||
createHooks: mockCreateHooks as never,
|
createHooks: mockCreateHooks as never,
|
||||||
createPluginInterface: mockCreatePluginInterface as never,
|
createPluginInterface: mockCreatePluginInterface as never,
|
||||||
@@ -91,6 +99,8 @@ describe("createPluginModule()", () => {
|
|||||||
mockInjectServerAuthIntoClient.mockClear()
|
mockInjectServerAuthIntoClient.mockClear()
|
||||||
mockLoadPluginConfig.mockClear()
|
mockLoadPluginConfig.mockClear()
|
||||||
mockCreateManagers.mockClear()
|
mockCreateManagers.mockClear()
|
||||||
|
mockRuntimeSkillSourceStop.mockClear()
|
||||||
|
mockCreateRuntimeSkillSourceServer.mockClear()
|
||||||
mockCreateTools.mockClear()
|
mockCreateTools.mockClear()
|
||||||
mockCreateHooks.mockClear()
|
mockCreateHooks.mockClear()
|
||||||
mockCreatePluginInterface.mockClear()
|
mockCreatePluginInterface.mockClear()
|
||||||
@@ -123,6 +133,71 @@ describe("createPluginModule()", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("#given bundled security skills are enabled", () => {
|
||||||
|
it("#then startup exposes them through a runtime skill source URL", async () => {
|
||||||
|
// given
|
||||||
|
const pluginModule = createTestPluginModule()
|
||||||
|
mockLoadPluginConfig.mockReturnValue({})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await pluginModule.server({
|
||||||
|
directory: "/tmp/project",
|
||||||
|
client: {},
|
||||||
|
} as Parameters<typeof pluginModule.server>[0])
|
||||||
|
|
||||||
|
// then
|
||||||
|
const sourceArgs = mockCreateRuntimeSkillSourceServer.mock.calls.at(0)?.[0]
|
||||||
|
expect(sourceArgs?.skills.map((skill) => skill.name)).toEqual([
|
||||||
|
"security-research",
|
||||||
|
"security-review",
|
||||||
|
])
|
||||||
|
expect(mockCreateManagers.mock.calls.at(0)?.[0]).toMatchObject({
|
||||||
|
runtimeSkillSourceUrl: "http://127.0.0.1:49152/security-research,security-review",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#then dispose stops the runtime skill source", async () => {
|
||||||
|
// given
|
||||||
|
const pluginModule = createTestPluginModule()
|
||||||
|
mockLoadPluginConfig.mockReturnValue({})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const hooks: Awaited<ReturnType<typeof pluginModule.server>> & {
|
||||||
|
dispose?: () => Promise<void>
|
||||||
|
} = await pluginModule.server({
|
||||||
|
directory: "/tmp/project",
|
||||||
|
client: {},
|
||||||
|
} as Parameters<typeof pluginModule.server>[0])
|
||||||
|
await hooks.dispose?.()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockRuntimeSkillSourceStop).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given security-research is disabled", () => {
|
||||||
|
it("#then startup still exposes security-review through the runtime skill source", async () => {
|
||||||
|
// given
|
||||||
|
const pluginModule = createTestPluginModule()
|
||||||
|
mockLoadPluginConfig.mockReturnValue({
|
||||||
|
disabled_skills: ["security-research"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await pluginModule.server({
|
||||||
|
directory: "/tmp/project",
|
||||||
|
client: {},
|
||||||
|
} as Parameters<typeof pluginModule.server>[0])
|
||||||
|
|
||||||
|
// then
|
||||||
|
const sourceArgs = mockCreateRuntimeSkillSourceServer.mock.calls.at(0)?.[0]
|
||||||
|
expect(sourceArgs?.skills.map((skill) => skill.name)).toEqual(["security-review"])
|
||||||
|
expect(mockCreateManagers.mock.calls.at(0)?.[0]).toMatchObject({
|
||||||
|
runtimeSkillSourceUrl: "http://127.0.0.1:49152/security-review",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("#given duplicate OMO plugin entries are configured", () => {
|
describe("#given duplicate OMO plugin entries are configured", () => {
|
||||||
it("#then startup warns and returns no prompt-producing hooks", async () => {
|
it("#then startup warns and returns no prompt-producing hooks", async () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { createHooks } from "../create-hooks"
|
|||||||
import { createManagers } from "../create-managers"
|
import { createManagers } from "../create-managers"
|
||||||
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "../create-runtime-tmux-config"
|
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "../create-runtime-tmux-config"
|
||||||
import { createTools } from "../create-tools"
|
import { createTools } from "../create-tools"
|
||||||
|
import { createRuntimeSkillSourceServer, selectRuntimeSecuritySkills } from "../features/opencode-runtime-skills"
|
||||||
import { initializeOpenClaw } from "../openclaw"
|
import { initializeOpenClaw } from "../openclaw"
|
||||||
|
import { createPluginDispose } from "../plugin-dispose"
|
||||||
import { createPluginInterface } from "../plugin-interface"
|
import { createPluginInterface } from "../plugin-interface"
|
||||||
import { loadPluginConfig } from "../plugin-config"
|
import { loadPluginConfig } from "../plugin-config"
|
||||||
import { createModelCacheState } from "../plugin-state"
|
import { createModelCacheState } from "../plugin-state"
|
||||||
@@ -30,8 +32,9 @@ import { migrateLegacyWorkspaceDirectory } from "../shared/legacy-workspace-migr
|
|||||||
import { injectServerAuthIntoClient } from "../shared/opencode-server-auth"
|
import { injectServerAuthIntoClient } from "../shared/opencode-server-auth"
|
||||||
import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash"
|
import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash"
|
||||||
|
|
||||||
type HooksWithCompactionAutocontinue = Hooks & {
|
type HooksWithRuntimeLifecycle = Hooks & {
|
||||||
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
||||||
|
dispose?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginModuleDeps = {
|
export type PluginModuleDeps = {
|
||||||
@@ -56,6 +59,7 @@ export type PluginModuleDeps = {
|
|||||||
createModelCacheState: typeof createModelCacheState
|
createModelCacheState: typeof createModelCacheState
|
||||||
createManagers: typeof createManagers
|
createManagers: typeof createManagers
|
||||||
createTools: typeof createTools
|
createTools: typeof createTools
|
||||||
|
createRuntimeSkillSourceServer: typeof createRuntimeSkillSourceServer
|
||||||
createHooks: typeof createHooks
|
createHooks: typeof createHooks
|
||||||
createPluginInterface: typeof createPluginInterface
|
createPluginInterface: typeof createPluginInterface
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,7 @@ const defaultPluginModuleDeps: PluginModuleDeps = {
|
|||||||
createModelCacheState,
|
createModelCacheState,
|
||||||
createManagers,
|
createManagers,
|
||||||
createTools,
|
createTools,
|
||||||
|
createRuntimeSkillSourceServer,
|
||||||
createHooks,
|
createHooks,
|
||||||
createPluginInterface,
|
createPluginInterface,
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,11 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
|||||||
deps.injectServerAuthIntoClient(input.client)
|
deps.injectServerAuthIntoClient(input.client)
|
||||||
|
|
||||||
const pluginConfig = deps.loadPluginConfig(input.directory, input)
|
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.initI18n(pluginConfig.i18n?.locale ? { locale: pluginConfig.i18n.locale } : undefined)
|
||||||
deps.setAgentSortOrder(pluginConfig.agent_order)
|
deps.setAgentSortOrder(pluginConfig.agent_order)
|
||||||
|
|
||||||
@@ -129,8 +139,12 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
|||||||
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
|
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.warn("[team-mode] init failed:", err)
|
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)
|
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
|
||||||
@@ -154,6 +168,7 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
|||||||
tmuxConfig,
|
tmuxConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
||||||
|
runtimeSkillSourceUrl: runtimeSkillSource?.url,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolsResult = await deps.createTools({
|
const toolsResult = await deps.createTools({
|
||||||
@@ -183,12 +198,23 @@ export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): P
|
|||||||
tools: toolsResult.filteredTools,
|
tools: toolsResult.filteredTools,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pluginHooks: HooksWithCompactionAutocontinue = {
|
const dispose = createPluginDispose({
|
||||||
|
backgroundManager: managers.backgroundManager,
|
||||||
|
skillMcpManager: managers.skillMcpManager,
|
||||||
|
disposeHooks: hooks.disposeHooks,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pluginHooks: HooksWithRuntimeLifecycle = {
|
||||||
...pluginInterface,
|
...pluginInterface,
|
||||||
|
|
||||||
"experimental.session.compacting": createSessionCompactingHandler(hooks),
|
"experimental.session.compacting": createSessionCompactingHandler(hooks),
|
||||||
|
|
||||||
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
|
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
|
||||||
|
|
||||||
|
dispose: async (): Promise<void> => {
|
||||||
|
runtimeSkillSource?.stop()
|
||||||
|
await dispose()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return pluginHooks
|
return pluginHooks
|
||||||
|
|||||||
@@ -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", () => {
|
describe("skill tool - short name resolution", () => {
|
||||||
it("resolves namespaced skill by short name when unambiguous", async () => {
|
it("resolves namespaced skill by short name when unambiguous", async () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
Reference in New Issue
Block a user