fix: resolve 25 pre-publish blockers

- postinstall.mjs: fix alias package detection
- migrate-legacy-plugin-entry: dedupe + regression tests
- task_system: default consistency across runtime paths
- task() contract: consistent tool behavior
- runtime model selection, tool cap, stale-task cancellation
- recovery sanitization, context-limit gating
- Ralph semantic DONE hardening, Atlas fallback persistence
- native-skill description/content, skill path traversal guard
- publish workflow: platform awaited via reusable workflow job
- release: version edits reapplied before commit/tag
- JSONC plugin migration: top-level plugin key safety
- cold-cache: user fallback models skip disconnected providers
- docs/version/release framing updates

Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
This commit is contained in:
YeonGyu-Kim
2026-03-28 15:24:18 +09:00
parent 44b039bef6
commit d2c576c510
62 changed files with 1264 additions and 292 deletions
+36 -2
View File
@@ -45,7 +45,7 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000)
})
it("returns cached limit for Anthropic models when modelContextLimitsCache has entry", () => {
it("returns default 200K for older Anthropic models when 1M mode is disabled", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
@@ -59,7 +59,7 @@ describe("resolveActualContextLimit", () => {
})
// then
expect(actualLimit).toBe(500_000)
expect(actualLimit).toBe(200_000)
})
it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => {
@@ -126,6 +126,40 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000)
})
it("supports Anthropic 4.6 high-variant model IDs without widening older models", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(500_000)
})
it("ignores stale cached limits for older Anthropic models with suffixed IDs", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(200_000)
})
it("returns null for non-Anthropic providers without a cached limit", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
+5 -1
View File
@@ -19,6 +19,10 @@ function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState):
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
function supportsCachedAnthropicLimit(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)6(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit(
providerID: string,
modelID: string,
@@ -29,7 +33,7 @@ export function resolveActualContextLimit(
if (explicit1M === 1_000_000) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit) return cachedLimit
if (cachedLimit && supportsCachedAnthropicLimit(modelID)) return cachedLimit
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
+93 -6
View File
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { migrateLegacyPluginEntry } from "./migrate-legacy-plugin-entry"
async function importFreshMigrationModule(): Promise<typeof import("./migrate-legacy-plugin-entry")> {
return import(`./migrate-legacy-plugin-entry?test=${Date.now()}-${Math.random()}`)
}
describe("migrateLegacyPluginEntry", () => {
let testDir = ""
@@ -18,9 +21,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains oh-my-opencode plugin entry", () => {
describe("#when migrating the config", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -34,9 +38,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains bare oh-my-opencode entry", () => {
describe("#when migrating the config", () => {
it("#then replaces with oh-my-openagent", () => {
it("#then replaces with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -50,9 +55,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains pinned oh-my-opencode version", () => {
describe("#when migrating the config", () => {
it("#then preserves the version pin", () => {
it("#then preserves the version pin", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.11.0"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -65,10 +71,11 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json already uses oh-my-openagent", () => {
describe("#when checking for migration", () => {
it("#then returns false and does not modify the file", () => {
it("#then returns false and does not modify the file", async () => {
const configPath = join(testDir, "opencode.json")
const original = JSON.stringify({ plugin: ["oh-my-openagent@latest"] }, null, 2)
writeFileSync(configPath, original)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -78,9 +85,89 @@ describe("migrateLegacyPluginEntry", () => {
})
})
describe("#given plugin entries contain both canonical and legacy values", () => {
describe("#when migrating the config", () => {
it("#then removes the legacy entry instead of duplicating the canonical one", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as { plugin: string[] }
expect(saved.plugin).toEqual(["oh-my-openagent"])
})
})
})
describe("#given unrelated strings contain the legacy package name", () => {
describe("#when migrating the config", () => {
it("#then rewrites only plugin entries and preserves unrelated fields", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(
configPath,
JSON.stringify(
{
plugin: ["oh-my-opencode"],
notes: "keep oh-my-opencode in this text field",
paths: ["/tmp/oh-my-opencode/cache"],
},
null,
2,
),
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as {
plugin: string[]
notes: string
paths: string[]
}
expect(saved.plugin).toEqual(["oh-my-openagent"])
expect(saved.notes).toBe("keep oh-my-opencode in this text field")
expect(saved.paths).toEqual(["/tmp/oh-my-opencode/cache"])
})
})
})
describe("#given opencode.jsonc contains a nested plugin key before the top-level plugin array", () => {
describe("#when migrating the config", () => {
it("#then rewrites only the top-level plugin array", async () => {
const configPath = join(testDir, "opencode.jsonc")
writeFileSync(
configPath,
`{
"nested": {
"plugin": ["oh-my-opencode"]
},
"plugin": ["oh-my-opencode@latest"]
}
`,
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const content = readFileSync(configPath, "utf-8")
expect(content).toContain(`"nested": {
"plugin": ["oh-my-opencode"]
}`)
expect(content).toContain(`"plugin": [
"oh-my-openagent@latest"
]`)
})
})
})
describe("#given config file does not exist", () => {
describe("#when attempting migration", () => {
it("#then returns false", () => {
it("#then returns false", async () => {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(join(testDir, "nonexistent.json"))
expect(result).toBe(false)
+55 -2
View File
@@ -1,8 +1,54 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { applyEdits, modify } from "jsonc-parser"
import { parseJsoncSafe } from "./jsonc-parser"
import { log } from "./logger"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity"
interface OpenCodeConfig {
plugin?: string[]
}
function isLegacyEntry(entry: string): boolean {
return entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)
}
function isCanonicalEntry(entry: string): boolean {
return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)
}
function toCanonicalEntry(entry: string): string {
if (entry === LEGACY_PLUGIN_NAME) return PLUGIN_NAME
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
return `${PLUGIN_NAME}${entry.slice(LEGACY_PLUGIN_NAME.length)}`
}
return entry
}
function normalizePluginEntries(entries: string[]): string[] {
const hasCanonical = entries.some(isCanonicalEntry)
if (hasCanonical) {
return entries.filter((entry) => !isLegacyEntry(entry))
}
return entries.map((entry) => (isLegacyEntry(entry) ? toCanonicalEntry(entry) : entry))
}
function updateJsoncPluginArray(content: string, pluginEntries: string[]): string | null {
const edits = modify(content, ["plugin"], pluginEntries, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
getInsertionIndex: () => 0,
})
if (edits.length === 0) return null
return applyEdits(content, edits)
}
export function migrateLegacyPluginEntry(configPath: string): boolean {
if (!existsSync(configPath)) return false
@@ -10,8 +56,15 @@ export function migrateLegacyPluginEntry(configPath: string): boolean {
const content = readFileSync(configPath, "utf-8")
if (!content.includes(LEGACY_PLUGIN_NAME)) return false
const updated = content.replaceAll(LEGACY_PLUGIN_NAME, PLUGIN_NAME)
if (updated === content) return false
const parseResult = parseJsoncSafe<OpenCodeConfig>(content)
const pluginEntries = parseResult.data?.plugin
if (!pluginEntries || !pluginEntries.some(isLegacyEntry)) return false
const updatedPluginEntries = normalizePluginEntries(pluginEntries)
const updated = configPath.endsWith(".jsonc")
? updateJsoncPluginArray(content, updatedPluginEntries)
: JSON.stringify({ ...(parseResult.data as OpenCodeConfig), plugin: updatedPluginEntries }, null, 2) + "\n"
if (!updated || updated === content) return false
writeFileSync(configPath, updated, "utf-8")
log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", {
+24
View File
@@ -125,4 +125,28 @@ describe("resolveSkillPathReferences", () => {
//#then
expect(result).toBe("/skills/frontend/scripts/search.py")
})
it("does not resolve traversal paths that escape the base directory", () => {
//#given
const content = "Read @data/../../../../etc/passwd before running"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Read @data/../../../../etc/passwd before running")
})
it("does not resolve directory traversal with trailing slash", () => {
//#given
const content = "Inspect @data/../../../secret/"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Inspect @data/../../../secret/")
})
})
+10 -11
View File
@@ -1,4 +1,4 @@
import { join } from "path"
import { isAbsolute, relative, resolve, sep } from "node:path"
function looksLikeFilePath(path: string): boolean {
if (path.endsWith("/")) return true
@@ -6,22 +6,21 @@ function looksLikeFilePath(path: string): boolean {
return /\.[a-zA-Z0-9]+$/.test(lastSegment)
}
/**
* Resolves @path references in skill content to absolute paths.
*
* Matches @references that contain at least one slash (e.g., @scripts/search.py, @data/)
* to avoid false positives with decorators (@param), JSDoc tags (@ts-ignore), etc.
* Also skips npm scoped packages (@scope/package) by requiring a file extension or trailing slash.
*
* Email addresses are excluded since they have alphanumeric characters before @.
*/
export function resolveSkillPathReferences(content: string, basePath: string): string {
const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath
return content.replace(
/(?<![a-zA-Z0-9])@([a-zA-Z0-9_-]+\/[a-zA-Z0-9_.\-\/]*)/g,
(match, relativePath: string) => {
if (!looksLikeFilePath(relativePath)) return match
return join(normalizedBase, relativePath)
const resolvedPath = resolve(normalizedBase, relativePath)
const relativePathFromBase = relative(normalizedBase, resolvedPath)
if (relativePathFromBase.startsWith("..") || isAbsolute(relativePathFromBase)) {
return match
}
if (relativePath.endsWith("/") && !resolvedPath.endsWith(sep)) {
return `${resolvedPath}/`
}
return resolvedPath
}
)
}