fix(skills): complete plugin-cache scans without false attention (#10865)

Fixes the P0 where skill cards showed an unclearable amber "Needs attention"
while the Details dialog reported everything up to date.

Root cause: when the plugin-cache scan tripped one of its own bounds it recorded
an incomplete path, and inventorySkillFreshness expanded that into one fabricated
placement per manifest skill at a path it never stat'ed. Those synthetic
"inaccessible" copies lit the pill, were filtered out of the dialog, and could
never be cleared because plugin-cache is not an updatable topology.

- Removes the fabrication; reports typed scan issues instead.
- Requires readable SKILL.md evidence before promoting a directory to a candidate,
  so a same-named foreign plugin (Codex's own computer-use) no longer flags.
- Prunes skill payloads and node_modules so ordinary vendor caches stop tripping
  the depth and entry bounds.
- Partitions scan reasons: only a real read failure raises a pill; bounds that
  ended the walk block an all-clear claim; the rest are Details-only.

Fixes #10633. Refs #10659, #10904, #10918, #10775, #10791, #10813.
This commit is contained in:
Hansss 2026-07-28 11:11:57 +09:00 committed by GitHub
parent 025ceec04e
commit 2cf91b8e69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1657 additions and 120 deletions

View File

@ -77,6 +77,7 @@ describe('registerSkillsHandlers', () => {
schemaVersion: 1,
installations: [],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 1
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')

View File

@ -311,6 +311,111 @@ describe('read-only skill freshness inventory', () => {
}
)
it('keeps another ecosystems same-name plugin skill unrecognized', async () => {
// Why: Codex ships its own `computer-use` plugin. Reported in #10633 — the copy is
// not ours, not the user's to delete, and left amber with no action available.
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
const pluginRoot = join(
test.homeDir,
'.codex',
'plugins',
'cache',
'openai-bundled',
'orca-cli'
)
await mkdir(pluginRoot, { recursive: true })
await writeFile(join(pluginRoot, 'SKILL.md'), '---\nname: orca-cli\n---\n\nAnother tool.\n')
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations).toEqual(
expect.arrayContaining([
expect.objectContaining({
unresolvedPath: pluginRoot,
topology: 'plugin-cache',
status: 'unrecognized'
})
])
)
expect(inventory.eligibleUpdateNames).toEqual([])
})
it('keeps a plugin-cache copy with known official files unrecognized', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
const modifiedRoot = join(
test.homeDir,
'.codex',
'plugins',
'cache',
'openai-bundled',
'modified',
'orca-cli'
)
await mkdir(modifiedRoot, { recursive: true })
await writeFile(join(modifiedRoot, 'SKILL.md'), test.currentMarkdown)
await writeFile(join(modifiedRoot, 'README.md'), 'Modified official package\n')
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations).toEqual(
expect.arrayContaining([
expect.objectContaining({
unresolvedPath: modifiedRoot,
status: 'unrecognized'
})
])
)
})
it.each([
['.claude', 'skills'],
['.agents', 'skills']
])('keeps an unrecognized copy under %s/%s the users to review', async (...segments) => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
await test.writeSkill(
join(test.homeDir, ...segments),
'---\nname: orca-cli\n---\n\nAnother tool.\n'
)
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations.some((entry) => entry.status === 'unrecognized')).toBe(true)
})
it('does not classify an empty plugin-cache directory as a skill', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
const emptyRoot = join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'orca-cli')
await mkdir(emptyRoot, { recursive: true })
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations.some((entry) => entry.unresolvedPath === emptyRoot)).toBe(false)
})
it('accepts CRLF as the same official text identity', async () => {
const test = await fixture()
await test.writeSkill(
@ -377,4 +482,79 @@ describe('read-only skill freshness inventory', () => {
// command does not touch, so the limit is reported without blocking the update.
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
})
it('scans a real-shaped plugin cache completely and leaves eligibility unchanged', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const packageRoot = join(
test.homeDir,
'.codex',
'plugins',
'cache',
'openai-bundled',
'orca-cli',
'1.0.0'
)
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n')
const pluginSkill = await test.writeSkill(join(packageRoot, 'skills'), test.currentMarkdown)
await mkdir(join(pluginSkill, 'templates', 'starter', 'examples', 'd1', 'app', 'api'), {
recursive: true
})
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
// The plugin copy is real and reported at its own path — never at a joined path,
// and never at the same-named plugin directory two levels above it.
expect(inventory.scanIssues).toEqual([])
expect(inventory.installations).toEqual(
expect.arrayContaining([
expect.objectContaining({ topology: 'plugin-cache', unresolvedPath: pluginSkill })
])
)
// Why: a plugin-cache copy is not convergent, so it neither grants nor withholds
// the update. The outdated canonical copy alone decides, exactly as before.
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
})
it('reports incomplete plugin coverage without inventing per-skill installations', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache')
await mkdir(join(pluginCache, ...Array.from({ length: 11 }, (_, index) => `level-${index}`)), {
recursive: true
})
const inventory = await inventorySkillFreshness({
currentAppVersion: '2.0.0',
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations).toHaveLength(1)
expect(inventory.installations[0]).toMatchObject({
name: 'orca-cli',
status: 'current',
topology: 'canonical-copy'
})
expect(inventory.installations).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ errorCategory: 'plugin-cache-scan-incomplete' })
])
)
expect(inventory.scanIssues).toEqual([
expect.objectContaining({
rootId: 'codex-plugin-cache',
sourceLabel: 'Codex plugin cache',
reason: 'depth-limit',
errorCode: null
})
])
})
})

View File

@ -123,8 +123,8 @@ export async function inventorySkillFreshness(args: {
scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys()))
}))
)
const pluginTasks = pluginScans.flatMap(({ root, scan }) => [
...scan.candidates.flatMap((candidate) => {
const pluginTasks = pluginScans.flatMap(({ root, scan }) =>
scan.candidates.flatMap((candidate) => {
const current = currentByName.get(candidate.name)
return current
? [
@ -139,31 +139,15 @@ export async function inventorySkillFreshness(args: {
})
]
: []
}),
// Why: unreadable plugin subtrees could hide any official name. An
// incomplete scan must conservatively poison every name rather than imply absence.
...scan.incompletePaths.flatMap((incompletePath) =>
artifacts.manifest.skills.map(
(current) => () =>
observeSkillFreshnessInstallation({
current,
currentAppVersion: args.currentAppVersion,
artifacts,
rootId: root.id,
providers: root.providers,
sourceKind: 'plugin',
sourceLabel: root.label,
unresolvedPath: join(incompletePath, current.name),
topology: {
topology: 'plugin-cache',
resolvedPath: null,
identity: null,
errorCategory: 'plugin-cache-scan-incomplete'
}
})
)
)
])
})
)
const scanIssues = pluginScans.flatMap(({ root, scan }) =>
scan.issues.map((issue) => ({
rootId: root.id,
sourceLabel: root.label,
...issue
}))
)
const unsupportedInstallations = (
await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks])
).filter((installation): installation is SkillFreshnessInstallation => installation !== null)
@ -180,6 +164,7 @@ export async function inventorySkillFreshness(args: {
schemaVersion: 1,
installations,
eligibleUpdateNames: eligibleSkillUpdateNames(installations),
scanIssues,
scannedAt: Date.now()
}
}

View File

@ -1,10 +1,16 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
import {
MAXIMUM_PLUGIN_SCAN_ISSUES,
scanKnownPluginSkillCandidates
} from './skill-plugin-cache-scan'
const temporaryDirectories: string[] = []
const execFileAsync = promisify(execFile)
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
@ -15,16 +21,84 @@ describe('plugin skill candidate scan', () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-'))
temporaryDirectories.push(root)
await Promise.all(
['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true }))
['one', 'two'].map(async (vendor) => {
await mkdir(join(root, vendor, 'orca-cli'), { recursive: true })
await writeFile(join(root, vendor, 'orca-cli', 'SKILL.md'), '# Orca CLI\n')
})
)
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1)
expect(result.candidates).toHaveLength(1)
expect(result.incompletePaths).toEqual([root])
expect(result.issues).toEqual([{ path: root, reason: 'candidate-limit', errorCode: null }])
})
it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => {
it('completes a real-shaped Codex cache without reporting coverage issues', async () => {
// Mirrors ~/.codex/plugins/cache: <vendor>/<plugin>/<version>/.codex-plugin, with the
// skill's own payload nesting well past the raw traversal depth (issue #10659).
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-real-shape-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'openai-bundled', 'sites', '0.1.31')
const skill = join(packageRoot, 'skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(
join(skill, 'templates', 'vinext-starter', 'examples', 'd1', 'app', 'api', 'deep'),
{ recursive: true }
)
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n')
await writeFile(join(skill, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [{ name: 'orca-cli', path: skill }], issues: [] })
})
it('does not emit a plugin directory that only shares a skill name', async () => {
// The cached plugin is itself called orca-cli. Only the SKILL.md below it is a skill.
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-name-collision-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'openai-bundled', 'orca-cli', '1.0.0')
const skill = join(packageRoot, 'skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(skill, { recursive: true })
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n')
await writeFile(join(skill, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [{ name: 'orca-cli', path: skill }], issues: [] })
})
it('does not emit a bare known-name directory that carries no SKILL.md', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-bare-name-'))
temporaryDirectories.push(root)
await mkdir(join(root, 'vendor', 'orca-cli', 'assets'), { recursive: true })
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [], issues: [] })
})
it('stops descending once a skill package payload exceeds the nested skill budget', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-payload-prune-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const skill = join(packageRoot, 'skills', 'sites-building')
const buried = join(skill, 'templates', 'starter', 'examples', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(buried, { recursive: true })
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n')
await writeFile(join(skill, 'SKILL.md'), '# Sites building\n')
await writeFile(join(buried, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
// Why: pruning payload is a topology decision, so it must stay silent rather than
// surface as a coverage issue the user is asked to act on.
expect(result).toEqual({ candidates: [], issues: [] })
})
it('reports a depth-truncated subtree as scan coverage instead of a skill candidate', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-'))
temporaryDirectories.push(root)
const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`)
@ -34,7 +108,564 @@ describe('plugin skill candidate scan', () => {
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.candidates).toEqual([])
expect(result.incompletePaths).toHaveLength(1)
expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true)
expect(result.issues).toHaveLength(1)
expect(result.issues[0]).toMatchObject({ reason: 'depth-limit', errorCode: null })
expect(hiddenSkill.startsWith(result.issues[0]?.path ?? '')).toBe(true)
})
it('does not scan dependency packages for plugin skill entrypoints', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-dependencies-'))
temporaryDirectories.push(root)
await mkdir(
join(
root,
'vendor',
'plugin',
'scripts',
'node_modules',
...Array.from({ length: 12 }, (_, index) => `level-${index}`)
),
{ recursive: true }
)
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [], issues: [] })
})
it('scans only declared Codex plugin skill roots', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-roots-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'custom-skills', 'group', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await mkdir(
join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)),
{ recursive: true }
)
await writeFile(
join(packageRoot, '.codex-plugin', 'plugin.json'),
'{"skills":["./custom-skills","./skills"]}\n'
)
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('uses the default skills root for compatible manifests without a skills field', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-default-root-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'skills', 'nested', 'orca-cli')
await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await writeFile(join(packageRoot, '.claude-plugin', 'plugin.json'), '{"name":"plugin"}\n')
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('falls back to traversal when a manifest skills path is invalid', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-invalid-declared-root-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'custom-skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await writeFile(
join(packageRoot, '.codex-plugin', 'plugin.json'),
'{"skills":"custom-skills"}\n'
)
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('does not traverse plugin payload when the default skills root is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-missing-default-root-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(
join(packageRoot, 'commands', ...Array.from({ length: 11 }, (_, index) => `level-${index}`)),
{ recursive: true }
)
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"name":"plugin"}\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [], issues: [] })
})
it('does not traverse plugin payload when the manifest declares no skill roots', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-empty-skill-roots-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(
join(packageRoot, 'commands', ...Array.from({ length: 11 }, (_, index) => `level-${index}`)),
{ recursive: true }
)
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":[]}\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [], issues: [] })
})
it('discovers nested skill packages recursively within declared roots', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-boundary-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const skillRoot = join(packageRoot, 'skills', 'sites-building')
await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true })
await mkdir(join(skillRoot, 'templates', 'orca-cli'), { recursive: true })
await writeFile(join(packageRoot, '.claude-plugin', 'plugin.json'), '{"skills":"./skills"}\n')
await writeFile(join(skillRoot, 'SKILL.md'), '# Sites building\n')
await writeFile(join(skillRoot, 'templates', 'orca-cli', 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: join(skillRoot, 'templates', 'orca-cli') }],
issues: []
})
})
it('rejects Windows parent traversal without hiding the default skills root', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-windows-parent-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await writeFile(
join(packageRoot, '.codex-plugin', 'plugin.json'),
'{"skills":"./..\\\\outside"}\n'
)
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('falls through empty manifest directories to the first manifest file', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-precedence-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'custom-skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await mkdir(
join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)),
{ recursive: true }
)
await writeFile(
join(packageRoot, '.claude-plugin', 'plugin.json'),
'{"skills":"./custom-skills"}\n'
)
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('bounds how many skill roots one manifest can declare', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-budget-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const manifestPath = join(packageRoot, '.codex-plugin', 'plugin.json')
const candidate = join(packageRoot, 'r0000', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
await writeFile(
manifestPath,
JSON.stringify({
skills: Array.from({ length: 4096 }, (_, index) => `./r${String(index).padStart(4, '0')}`)
})
)
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
// Why: resolving declared roots bypasses the entry budget, so without this cap the
// manifest alone decides how long the scan runs. Falling back to the bounded walk
// still finds the skill, so the cap costs coverage nothing.
expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }])
expect(result.issues).toEqual([
{ path: manifestPath, reason: 'manifest-limit', errorCode: null }
])
})
it('keeps valid roots when a skills array contains an invalid value', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-invalid-root-array-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const defaultCandidate = join(packageRoot, 'skills', 'orca-cli')
const declaredCandidate = join(packageRoot, 'custom-skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(defaultCandidate, { recursive: true })
await mkdir(declaredCandidate, { recursive: true })
await writeFile(
join(packageRoot, '.codex-plugin', 'plugin.json'),
'{"skills":["./custom-skills",7]}\n'
)
await writeFile(join(defaultCandidate, 'SKILL.md'), '# Wrong root\n')
await writeFile(join(declaredCandidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: declaredCandidate }],
issues: []
})
})
it('does not reset the depth budget across nested plugin manifests', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-nested-manifests-'))
temporaryDirectories.push(root)
let pluginRoot = join(root, 'vendor', 'plugin', '1.0.0')
for (let index = 0; index < 8; index += 1) {
await mkdir(join(pluginRoot, '.codex-plugin'), { recursive: true })
await writeFile(join(pluginRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n')
pluginRoot = join(pluginRoot, 'skills', `nested-${index}`)
}
const hiddenCandidate = join(pluginRoot, 'orca-cli')
await mkdir(hiddenCandidate, { recursive: true })
await writeFile(join(hiddenCandidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.candidates).toEqual([])
expect(result.issues).toContainEqual(
expect.objectContaining({ reason: 'depth-limit', errorCode: null })
)
})
it('ignores non-directory manifest markers when selecting precedence', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-marker-file-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin', '1.0.0')
const candidate = join(packageRoot, 'custom-skills', 'orca-cli')
await mkdir(packageRoot, { recursive: true })
await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true })
await mkdir(candidate, { recursive: true })
await mkdir(
join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)),
{ recursive: true }
)
await writeFile(join(packageRoot, '.codex-plugin'), 'not a directory\n')
await writeFile(
join(packageRoot, '.claude-plugin', 'plugin.json'),
'{"skills":"./custom-skills"}\n'
)
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: []
})
})
it('reports manifests that exceed the bounded read limit', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-large-manifest-'))
temporaryDirectories.push(root)
const manifestPath = join(root, 'vendor', 'plugin', '.codex-plugin', 'plugin.json')
await mkdir(join(root, 'vendor', 'plugin', '.codex-plugin'), { recursive: true })
await writeFile(manifestPath, ' '.repeat(256 * 1024 + 1))
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.issues).toContainEqual({
path: manifestPath,
reason: 'manifest-limit',
errorCode: null
})
})
// Why (this and every other skipIf below): creating a symlink on Windows needs
// elevation or Developer Mode, so these would fail EPERM in setup rather than
// exercise the behavior under test. The non-symlink cases still run there.
it.skipIf(process.platform === 'win32')(
'reports a symlink target that cannot be inspected',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-symlink-error-'))
temporaryDirectories.push(root)
const linkPath = join(root, 'loop')
await symlink('loop', linkPath, 'dir')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.issues).toContainEqual({
path: linkPath,
reason: 'io-error',
errorCode: 'ELOOP'
})
}
)
it.skipIf(process.platform === 'win32')(
'preserves read failures when the scan issue limit is reached',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-limit-'))
temporaryDirectories.push(root)
await Promise.all(
Array.from({ length: 16 }, async (_, index) => {
const name = `loop-${index.toString().padStart(2, '0')}`
await symlink(name, join(root, name), 'dir')
})
)
const packageRoot = join(root, 'zz-package')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await symlink('SKILL.md', join(packageRoot, 'SKILL.md'), 'file')
await symlink('plugin.json', join(packageRoot, '.codex-plugin', 'plugin.json'), 'file')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.issues).toContainEqual({
path: join(root, 'loop-00'),
reason: 'io-error',
errorCode: 'ELOOP'
})
expect(result.issues).toContainEqual({
path: root,
reason: 'issue-limit',
errorCode: null
})
expect(result.issues.filter((issue) => issue.reason === 'issue-limit')).toHaveLength(1)
}
)
it('reports the bound that ended the walk even with the issue budget spent', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-truncating-issue-'))
temporaryDirectories.push(root)
await Promise.all(
Array.from({ length: 16 }, (_, index) =>
mkdir(
join(
root,
`deep-${index.toString().padStart(2, '0')}`,
...Array.from({ length: 11 }, (_, level) => `level-${level}`)
),
{ recursive: true }
)
)
)
await Promise.all(
['zz-one', 'zz-two'].map(async (vendor) => {
await mkdir(join(root, vendor, 'orca-cli'), { recursive: true })
await writeFile(join(root, vendor, 'orca-cli', 'SKILL.md'), '# Orca CLI\n')
})
)
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1)
// Why: the deep trees above exist to spend the display budget, so assert it is full —
// otherwise this passes with budget to spare and stops covering the case it is named
// for. Not 'issue-limit': that only appears when a non-required issue is dropped, and
// the truncating bound below bypasses the budget instead of being dropped by it.
expect(result.issues.filter((issue) => issue.reason === 'depth-limit')).toHaveLength(
MAXIMUM_PLUGIN_SCAN_ISSUES
)
// Why: losing this one to the display budget is what lets a scan that stopped early
// report all-clear — the bounds that merely skipped a folder say nothing about it.
expect(result.issues).toContainEqual({ path: root, reason: 'candidate-limit', errorCode: null })
})
it('keeps scanning past the issue budget', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-budget-coverage-'))
temporaryDirectories.push(root)
const candidate = join(root, 'zz-package', 'skills', 'orca-cli')
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
mkdir(
join(
root,
`deep-${index.toString().padStart(2, '0')}`,
...Array.from({ length: 11 }, (_, level) => `level-${level}`)
),
{ recursive: true }
)
)
)
await mkdir(candidate, { recursive: true })
await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
// Why: the issue budget bounds what the dialog lists, not how far the walk reaches.
// Ending the scan there would drop real copies and still report all-clear, because
// none of the bounds that filled the budget raise attention.
expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }])
expect(result.issues).toContainEqual({ path: root, reason: 'issue-limit', errorCode: null })
})
it.skipIf(process.platform === 'win32')(
'still names a fail-closed candidate once the issue budget is spent',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-budget-candidate-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'zz-package')
const candidate = join(packageRoot, 'skills', 'orca-cli')
await Promise.all(
Array.from({ length: 16 }, (_, index) =>
mkdir(
join(
root,
`deep-${index.toString().padStart(2, '0')}`,
...Array.from({ length: 11 }, (_, level) => `level-${level}`)
),
{ recursive: true }
)
)
)
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(join(packageRoot, 'skills'), { recursive: true })
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n')
await symlink('missing-target', candidate, 'dir')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
// Why: the depth bounds fill the issue budget first. Dropping this one for budget
// would leave the badge amber over a candidate the dialog never mentions.
expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }])
expect(result.issues).toContainEqual({
path: candidate,
reason: 'io-error',
errorCode: 'ENOENT'
})
}
)
it.skipIf(process.platform === 'win32')(
'names the path when a dangling known-name symlink is kept as a candidate',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-declared-dangling-symlink-'))
temporaryDirectories.push(root)
const packageRoot = join(root, 'vendor', 'plugin')
const candidate = join(packageRoot, 'skills', 'orca-cli')
await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true })
await mkdir(join(packageRoot, 'skills'), { recursive: true })
await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n')
await symlink('missing-target', candidate, 'dir')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
// Why: this candidate resolves to nothing, so it reads as inaccessible and raises
// attention. Without the issue the dialog would report all-clear against a badge
// that says otherwise, and nothing would ever name the broken link.
expect(result).toEqual({
candidates: [{ name: 'orca-cli', path: candidate }],
issues: [{ path: candidate, reason: 'io-error', errorCode: 'ENOENT' }]
})
}
)
it.skipIf(process.platform === 'win32')(
'does not follow directory symlinks outside the plugin cache',
async () => {
const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-symlink-outside-'))
temporaryDirectories.push(parent)
const root = join(parent, 'cache')
const outside = join(parent, 'outside')
const linkPath = join(root, 'vendor')
await mkdir(join(outside, 'orca-cli'), { recursive: true })
await mkdir(root, { recursive: true })
await writeFile(join(outside, 'orca-cli', 'SKILL.md'), '# Orca CLI\n')
await symlink(outside, linkPath, 'dir')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [],
issues: [{ path: linkPath, reason: 'outside-root', errorCode: null }]
})
}
)
it.skipIf(process.platform === 'win32')(
'does not follow a SKILL.md symlink outside the plugin cache',
async () => {
const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-file-outside-'))
temporaryDirectories.push(parent)
const root = join(parent, 'cache')
const skill = join(root, 'vendor', 'orca-cli')
const outsideSkillFile = join(parent, 'outside', 'SKILL.md')
await mkdir(skill, { recursive: true })
await mkdir(join(parent, 'outside'), { recursive: true })
await writeFile(outsideSkillFile, '# Orca CLI\n')
await symlink(outsideSkillFile, join(skill, 'SKILL.md'), 'file')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [],
issues: [{ path: join(skill, 'SKILL.md'), reason: 'outside-root', errorCode: null }]
})
}
)
it.skipIf(process.platform === 'win32')(
'does not read manifest symlinks outside the plugin cache',
async () => {
const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-outside-'))
temporaryDirectories.push(parent)
const root = join(parent, 'cache')
const outsideManifest = join(parent, 'plugin.json')
const manifestPath = join(root, '.codex-plugin', 'plugin.json')
await mkdir(join(root, '.codex-plugin'), { recursive: true })
await writeFile(outsideManifest, '{"skills":"./outside"}\n')
await symlink(outsideManifest, manifestPath, 'file')
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({
candidates: [],
issues: [{ path: manifestPath, reason: 'outside-root', errorCode: null }]
})
}
)
it.skipIf(process.platform === 'win32')(
'does not block on a manifest FIFO',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-fifo-'))
temporaryDirectories.push(root)
const manifestPath = join(root, '.codex-plugin', 'plugin.json')
await mkdir(join(root, '.codex-plugin'), { recursive: true })
await execFileAsync('mkfifo', [manifestPath])
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result).toEqual({ candidates: [], issues: [] })
},
1_000
)
})

View File

@ -1,20 +1,40 @@
import type { Dirent } from 'node:fs'
import { opendir, realpath, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import {
isTruncatingSkillScanReason,
type SkillFreshnessScanIssueReason
} from '../../shared/skill-freshness'
import { declaredPluginSkillRoots, isWithinRoot } from './skill-plugin-manifest-roots'
const MAXIMUM_PLUGIN_SCAN_DEPTH = 9
const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096
const MAXIMUM_DECLARED_SKILL_SCAN_DEPTH = 6
// Why: a skill package's own payload (templates, fixtures, sample apps) is not a skill
// tree, and it is what drives ordinary caches past the depth and entry bounds. Descend
// far enough to still find a skill grouped under a package, then stop.
const MAXIMUM_NESTED_SKILL_DEPTH = 2
// Why: sized against a real multi-vendor cache, which reads ~7k entries once payload is
// pruned. The bound still exists to stop a hostile or runaway tree; it is not a budget
// ordinary installs are meant to exhaust.
const MAXIMUM_PLUGIN_SCAN_ENTRIES = 16_384
export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64
const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16
export const MAXIMUM_PLUGIN_SCAN_ISSUES = 16
const SKILL_FILE_NAME = 'SKILL.md'
export type KnownPluginSkillCandidate = {
name: string
path: string
}
export type KnownPluginSkillScanIssue = {
path: string
reason: SkillFreshnessScanIssueReason
errorCode: string | null
}
export type KnownPluginSkillScan = {
candidates: KnownPluginSkillCandidate[]
incompletePaths: string[]
issues: KnownPluginSkillScanIssue[]
}
function errorCode(error: unknown): string | null {
@ -29,32 +49,101 @@ export async function scanKnownPluginSkillCandidates(
maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES
): Promise<KnownPluginSkillScan> {
const candidates: KnownPluginSkillCandidate[] = []
const incompletePaths = new Set<string>()
const issues: KnownPluginSkillScanIssue[] = []
const issueKeys = new Set<string>()
const visited = new Set<string>()
let resolvedRoot: string | null = null
let entryCount = 0
let limitReached = false
function recordIncomplete(path: string): void {
if (incompletePaths.has(path)) {
// Why: an issue that explains a candidate is not optional. Dropping it for budget
// leaves the badge reacting to a placement the dialog can't account for, which is
// the split this change exists to remove — so those are charged past the bound,
// itself bounded by the candidate cap.
function recordIssue(
path: string,
reason: KnownPluginSkillScanIssue['reason'],
code: string | null = null,
explainsCandidate = false
): void {
const key = `${path}\0${reason}\0${code ?? ''}`
if (issueKeys.has(key)) {
return
}
if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) {
// Why: each incomplete path expands to one conservative row per official
// skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout.
incompletePaths.clear()
incompletePaths.add(rootPath)
limitReached = true
// Why: the bound that ended the walk is the one issue the dialog cannot do without
// — dropping it for display budget is what lets a truncated scan report all-clear.
const required = explainsCandidate || isTruncatingSkillScanReason(reason)
// Why: this budget bounds what the dialog lists, not how far the scan reaches.
// Ending the walk here would truncate coverage over a display limit — and since
// Orca's own bounds no longer raise attention, it would do so silently.
if (!required && issues.length >= MAXIMUM_PLUGIN_SCAN_ISSUES) {
if (!issues.some((issue) => issue.reason === 'issue-limit')) {
issues.push({
path: rootPath,
reason: 'issue-limit',
errorCode: null
})
}
return
}
incompletePaths.add(path)
issueKeys.add(key)
issues.push({ path, reason, errorCode: code })
}
async function visit(directory: string, depth: number): Promise<void> {
function recordCandidate(name: string, path: string): void {
if (candidates.length >= maximumCandidates) {
limitReached = true
recordIssue(rootPath, 'candidate-limit')
return
}
candidates.push({ name, path })
}
// Why: a directory only proves it is a skill by carrying SKILL.md. Matching a known
// name alone would promote any same-named plugin or vendor folder into an installation
// Orca never verified.
async function hasSkillFile(
directory: string,
entries: readonly Dirent[],
resolvedDirectory: string
): Promise<boolean> {
const skillFile = entries.find((entry) => entry.name === SKILL_FILE_NAME)
if (!skillFile) {
return false
}
if (!skillFile.isSymbolicLink()) {
return skillFile.isFile()
}
try {
const skillFilePath = join(directory, skillFile.name)
const resolvedSkillFile = await realpath(skillFilePath)
if (!isWithinRoot(resolvedRoot ?? resolvedDirectory, resolvedSkillFile)) {
recordIssue(skillFilePath, 'outside-root')
return false
}
return (await stat(resolvedSkillFile)).isFile()
} catch (error) {
if (errorCode(error) !== 'ENOENT') {
recordIssue(join(directory, skillFile.name), 'io-error', errorCode(error))
}
return false
}
}
async function visit(
directory: string,
depth: number,
withinDeclaredSkillRoot = false,
payloadDepth: number | null = null
): Promise<void> {
if (limitReached) {
return
}
if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) {
recordIncomplete(directory)
const maximumDepth = withinDeclaredSkillRoot
? MAXIMUM_DECLARED_SKILL_SCAN_DEPTH
: MAXIMUM_PLUGIN_SCAN_DEPTH
if (depth > maximumDepth) {
recordIssue(directory, 'depth-limit')
return
}
let resolved: string
@ -62,10 +151,16 @@ export async function scanKnownPluginSkillCandidates(
resolved = await realpath(directory)
} catch (error) {
if (errorCode(error) !== 'ENOENT') {
recordIncomplete(directory)
recordIssue(directory, 'io-error', errorCode(error))
}
return
}
if (resolvedRoot === null) {
resolvedRoot = resolved
} else if (!isWithinRoot(resolvedRoot, resolved)) {
recordIssue(directory, 'outside-root')
return
}
if (visited.has(resolved)) {
return
}
@ -73,9 +168,9 @@ export async function scanKnownPluginSkillCandidates(
let handle: Awaited<ReturnType<typeof opendir>>
try {
handle = await opendir(directory)
} catch {
recordIncomplete(directory)
handle = await opendir(resolved)
} catch (error) {
recordIssue(directory, 'io-error', errorCode(error))
return
}
const entries: Dirent[] = []
@ -88,35 +183,83 @@ export async function scanKnownPluginSkillCandidates(
entryCount += 1
if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) {
limitReached = true
recordIncomplete(rootPath)
recordIssue(rootPath, 'entry-limit')
break
}
entries.push(entry)
}
} catch {
recordIncomplete(directory)
} catch (error) {
recordIssue(directory, 'io-error', errorCode(error))
} finally {
await handle.close().catch(() => undefined)
}
const isSkillPackage = await hasSkillFile(directory, entries, resolved)
if (isSkillPackage) {
const name = basename(directory)
if (knownNames.has(name)) {
recordCandidate(name, directory)
}
}
// Why: pruning payload is a topology decision, not a coverage failure, so it stays
// silent — recording it would put Orca's own traversal rules in the user's dialog.
const nextPayloadDepth = isSkillPackage ? 0 : payloadDepth === null ? null : payloadDepth + 1
if (nextPayloadDepth !== null && nextPayloadDepth > MAXIMUM_NESTED_SKILL_DEPTH) {
return
}
const skillRoots = await declaredPluginSkillRoots(directory, entries, resolvedRoot, recordIssue)
if (limitReached) {
return
}
if (skillRoots) {
const skillRootDepth = withinDeclaredSkillRoot ? depth + 1 : 0
for (const skillRoot of skillRoots.sort()) {
entryCount += 1
if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) {
limitReached = true
recordIssue(rootPath, 'entry-limit')
return
}
await visit(skillRoot, skillRootDepth, true)
}
return
}
entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1))
for (const entry of entries) {
if (limitReached) {
return
}
if (entry.name === 'node_modules') {
continue
}
const entryPath = join(directory, entry.name)
let directoryEntry = entry.isDirectory()
if (entry.isSymbolicLink()) {
try {
directoryEntry = (await stat(entryPath)).isDirectory()
} catch {
if (knownNames.has(entry.name)) {
if (candidates.length >= maximumCandidates) {
limitReached = true
recordIncomplete(rootPath)
return
if (directoryEntry) {
const resolvedEntry = await realpath(entryPath)
if (resolvedRoot !== null && !isWithinRoot(resolvedRoot, resolvedEntry)) {
recordIssue(entryPath, 'outside-root')
continue
}
candidates.push({ name: entry.name, path: entryPath })
}
} catch (error) {
const code = errorCode(error)
// Why: inside a declared skill root the plugin itself claims this name is a
// skill, so an uninspectable link stays fail-closed. Outside one there is no
// such claim and no SKILL.md to read, so inventing a copy would be a guess.
const claimedSkill = withinDeclaredSkillRoot && knownNames.has(entry.name)
// Why: a fail-closed candidate reads as inaccessible and raises attention, so
// the path has to be named even when it is merely absent.
if (claimedSkill) {
recordIssue(entryPath, 'io-error', code, true)
recordCandidate(entry.name, entryPath)
} else if (code !== 'ENOENT') {
recordIssue(entryPath, 'io-error', code)
}
continue
}
@ -124,19 +267,10 @@ export async function scanKnownPluginSkillCandidates(
if (!directoryEntry) {
continue
}
if (knownNames.has(entry.name)) {
if (candidates.length >= maximumCandidates) {
limitReached = true
recordIncomplete(rootPath)
return
}
candidates.push({ name: entry.name, path: entryPath })
continue
}
await visit(entryPath, depth + 1)
await visit(entryPath, depth + 1, withinDeclaredSkillRoot, nextPayloadDepth)
}
}
await visit(rootPath, 0)
return { candidates, incompletePaths: [...incompletePaths] }
return { candidates, issues }
}

View File

@ -0,0 +1,145 @@
import { constants, type Dirent } from 'node:fs'
import { open, realpath } from 'node:fs/promises'
import { isAbsolute, join, relative, sep } from 'node:path'
import type { SkillFreshnessScanIssueReason } from '../../shared/skill-freshness'
const MAXIMUM_PLUGIN_MANIFEST_BYTES = 256 * 1024
// Why: every declared root costs a resolve before it can be rejected, and those resolves
// bypass the dirent walk the entry budget bounds — so one manifest could otherwise spend
// the whole scan on paths that don't exist. No real plugin declares this many.
const MAXIMUM_DECLARED_SKILL_ROOTS = 64
// Why: only formats whose skill layout is known. Treating an unverified manifest as a
// declaration prunes the rest of that plugin, so a wrong guess hides real skills; with
// no manifest the ordinary walk still finds them.
const PLUGIN_MANIFEST_DIRECTORIES = ['.codex-plugin', '.claude-plugin'] as const
const MANIFEST_OPEN_FLAGS =
constants.O_RDONLY |
(process.platform === 'win32' ? 0 : constants.O_NONBLOCK | constants.O_NOFOLLOW)
function errorCode(error: unknown): string | null {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
}
export function isWithinRoot(root: string, path: string): boolean {
const relativePath = relative(root, path)
return !isAbsolute(relativePath) && relativePath.split(sep)[0] !== '..'
}
function resolveManifestSkillPath(directory: string, value: unknown): string | null {
if (typeof value !== 'string' || !value.startsWith('./')) {
return null
}
const relativePath = value.slice(2)
if (!relativePath || relativePath.split(/[\\/]/).includes('..')) {
return null
}
return join(directory, relativePath)
}
export async function declaredPluginSkillRoots(
directory: string,
entries: readonly Dirent[],
resolvedRoot: string,
recordIssue: (path: string, reason: SkillFreshnessScanIssueReason, code?: string | null) => void
): Promise<string[] | null> {
for (const manifestDirectory of PLUGIN_MANIFEST_DIRECTORIES) {
if (!entries.some((entry) => entry.name === manifestDirectory)) {
continue
}
const manifestPath = join(directory, manifestDirectory, 'plugin.json')
let resolvedManifestPath: string
try {
resolvedManifestPath = await realpath(manifestPath)
} catch (error) {
const code = errorCode(error)
if (code === 'ENOENT' || code === 'ENOTDIR') {
continue
}
recordIssue(manifestPath, 'io-error', code)
return null
}
if (!isWithinRoot(resolvedRoot, resolvedManifestPath)) {
recordIssue(manifestPath, 'outside-root')
return null
}
let manifestFile: Awaited<ReturnType<typeof open>>
try {
manifestFile = await open(resolvedManifestPath, MANIFEST_OPEN_FLAGS)
} catch (error) {
const code = errorCode(error)
if (code === 'ENOENT' || code === 'ENOTDIR') {
continue
}
recordIssue(manifestPath, 'io-error', code)
return null
}
try {
const manifestStat = await manifestFile.stat()
if (!manifestStat.isFile()) {
continue
}
if (manifestStat.size > MAXIMUM_PLUGIN_MANIFEST_BYTES) {
recordIssue(manifestPath, 'manifest-limit')
return null
}
const content = Buffer.alloc(MAXIMUM_PLUGIN_MANIFEST_BYTES + 1)
let contentLength = 0
while (contentLength < content.length) {
const { bytesRead } = await manifestFile.read(
content,
contentLength,
content.length - contentLength,
contentLength
)
if (bytesRead === 0) {
break
}
contentLength += bytesRead
}
if (contentLength > MAXIMUM_PLUGIN_MANIFEST_BYTES) {
recordIssue(manifestPath, 'manifest-limit')
return null
}
const parsed: unknown = JSON.parse(content.toString('utf8', 0, contentLength))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const skills = (parsed as Record<string, unknown>).skills
if (skills === undefined) {
return [join(directory, 'skills')]
}
if (Array.isArray(skills) && skills.length === 0) {
return []
}
const values = Array.isArray(skills) ? skills : [skills]
const roots = [
...new Set(
values
.map((value) => resolveManifestSkillPath(directory, value))
.filter((value): value is string => value !== null)
)
].sort()
if (roots.length === 0) {
return null
}
// Why: fall back to the ordinary walk rather than a truncated root list. The walk
// is bounded by depth and entries, so it costs less than resolving the declared
// roots one by one and still reaches skills a truncation would have dropped.
if (roots.length > MAXIMUM_DECLARED_SKILL_ROOTS) {
recordIssue(manifestPath, 'manifest-limit')
return null
}
return roots
} catch (error) {
if (!(error instanceof SyntaxError)) {
recordIssue(manifestPath, 'io-error', errorCode(error))
}
return null
} finally {
await manifestFile.close().catch(() => undefined)
}
}
return null
}

View File

@ -395,15 +395,13 @@ describe('CodexRestartChip focus target', () => {
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
})
useAppStore
.getState()
.markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
return healthyPaneInput
}

View File

@ -51,6 +51,7 @@ function eligibleInventory(): SkillFreshnessInventory {
schemaVersion: 1,
installations: [placement()],
eligibleUpdateNames: ['orca-cli'],
scanIssues: [],
scannedAt: 1
}
}
@ -154,6 +155,7 @@ describe('SkillFreshnessNudge', () => {
schemaVersion: 1,
installations: [placement({ status: 'current', observedPackageDigest: 'current' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 2
}
await rerenderNudge()
@ -212,6 +214,7 @@ describe('SkillFreshnessNudge', () => {
schemaVersion: 1,
installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 1
}

View File

@ -55,6 +55,7 @@ function inventory(
errorCategory: null
})),
eligibleUpdateNames,
scanIssues: [],
scannedAt: 1
}
}

View File

@ -118,6 +118,7 @@ function eligibleInventory(): SkillFreshnessInventory {
schemaVersion: 1,
installations: [placement('orca-cli')],
eligibleUpdateNames: ['orca-cli'],
scanIssues: [],
scannedAt: 1
}
}
@ -263,6 +264,7 @@ describe('SkillFreshnessUpdateDialog', () => {
schemaVersion: 1,
installations: [placement('orca-cli'), placement('orchestration')],
eligibleUpdateNames: ['orca-cli', 'orchestration'],
scanIssues: [],
scannedAt: 1
}
await renderDialog()
@ -304,6 +306,7 @@ describe('SkillFreshnessUpdateDialog', () => {
schemaVersion: 1,
installations: [placement('orca-cli', { status: 'current', installedReleaseRevision: 2 })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 5
}
await emitRun({ state: 'success', names: ['orca-cli'], finishedAt: 2, output: 'done' })
@ -325,6 +328,7 @@ describe('SkillFreshnessUpdateDialog', () => {
})
],
eligibleUpdateNames: ['orca-cli'],
scanIssues: [],
scannedAt: 1
}
await renderDialog()
@ -383,6 +387,7 @@ describe('SkillFreshnessUpdateDialog', () => {
schemaVersion: 1,
installations: [placement('orca-cli', { status: 'current', installedReleaseRevision: 2 })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 2
}
await renderDialog()
@ -397,6 +402,7 @@ describe('SkillFreshnessUpdateDialog', () => {
schemaVersion: 1,
installations: [placement('computer-use', { topology: 'repo-scope' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 3
}
await renderDialog()
@ -549,4 +555,104 @@ describe('SkillFreshnessUpdateDialog', () => {
expect(container?.querySelector('[data-skill-row="orca-cli"]')).toBeNull()
expect(findButton('Update 1 skill')).toBeUndefined()
})
it('shows incomplete plugin coverage without presenting a fabricated skill copy', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
eligibleUpdateNames: [],
scanIssues: [
{
rootId: 'codex-plugin-cache',
sourceLabel: 'Codex plugin cache',
path: '/home/.codex/plugins/cache/vendor/locked',
reason: 'io-error',
errorCode: 'EACCES'
}
],
scannedAt: 2
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain(
'Orca could not finish checking plugin-managed skills.'
)
expect(container?.textContent).toContain('/home/.codex/plugins/cache/vendor/locked')
expect(container?.textContent).toContain('EACCES')
expect(container?.textContent).not.toContain('All installed Orca skills are up to date.')
// Why: the fabricated per-skill path is exactly what this change removed — the
// unreadable folder must never be rendered as a copy of a named skill.
expect(container?.textContent).not.toContain(
'/home/.codex/plugins/cache/vendor/locked/orca-cli'
)
})
// Why: the walk stopped early here, so claiming every copy is up to date would assert
// a completeness the scan did not reach — green dishonesty in place of amber.
it.each(['entry-limit', 'candidate-limit'] as const)(
'does not report all-clear when %s ended the scan early',
async (reason) => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
eligibleUpdateNames: [],
scanIssues: [
{
rootId: 'codex-plugin-cache',
sourceLabel: 'Codex plugin cache',
path: '/home/.codex/plugins/cache',
reason: reason,
errorCode: null
}
],
scannedAt: 2
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).not.toContain('All installed Orca skills are up to date.')
expect(container?.textContent).toContain(
'Orca could not finish checking plugin-managed skills.'
)
// Why: the headline alone would pass with the folder list gone, leaving the user
// told the scan stopped but never told where. Assert the diagnostic renders too.
expect(container?.textContent).toContain('/home/.codex/plugins/cache')
}
)
// Why: Orca's own traversal bounds are not the user's to act on. Headlining them
// would put a permanent warning on any ordinary large plugin cache while every
// skill badge stayed green — the same unclearable amber, moved into the dialog.
it('lists a traversal bound without headlining it', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
eligibleUpdateNames: [],
scanIssues: [
{
rootId: 'codex-plugin-cache',
sourceLabel: 'Codex plugin cache',
path: '/home/.codex/plugins/cache/vendor/deep',
reason: 'depth-limit',
errorCode: null
}
],
scannedAt: 2
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
expect(container?.textContent).not.toContain(
'Orca could not finish checking plugin-managed skills.'
)
expect(container?.textContent).toContain('/home/.codex/plugins/cache/vendor/deep')
expect(container?.textContent).toContain('scan depth limit')
})
})

View File

@ -2,6 +2,8 @@ import { useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { AlertTriangle, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw } from 'lucide-react'
import {
buildTargetedSkillUpdateCommand,
isSkillScanIssueNeedingAttention,
isSkillScanIssueTruncatingScan,
type SkillFreshnessInventory
} from '../../../../shared/skill-freshness'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
@ -18,6 +20,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { TooltipProvider } from '@/components/ui/tooltip'
import { groupSkillFreshness } from './skill-freshness-grouping'
import { SkillFreshnessScanIssues } from './skill-freshness-scan-issues'
import { SkillUpdateRow } from './SkillUpdateRow'
import { SummaryHeadline, summarizeInventory } from './skill-freshness-summary-headline'
import {
@ -102,11 +105,20 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
)
const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update')
const blockedCount = groups.filter((group) => group.status === 'cannot-update').length
// Retained: the list keeps the last known folders on screen through a re-scan, the
// same way the rows above stay put rather than blanking.
const scanIssues = inventory?.scanIssues ?? []
// Why: the headline reads the LIVE snapshot, not the retained one — the two
// disagree for the whole loading window, and pairing a retained "eligible" with
// a live count of 0 renders "0 updates available" over rows badged "Update
// available". Live means it says "Checking…" over the rows it kept on screen.
const summaryKind = summarizeInventory(state.inventory, hasBlockedGroup)
const summaryKind = summarizeInventory(
state.inventory,
hasBlockedGroup,
(state.inventory?.scanIssues ?? []).some(
(issue) => isSkillScanIssueNeedingAttention(issue) || isSkillScanIssueTruncatingScan(issue)
)
)
// Why: one row list for every state. The rows are identical objects across the
// transition, so pressing Update changes each row's leading icon in place
@ -300,6 +312,14 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
</div>
) : null}
{/* Why: folders, not skills a plugin path Orca could not read says nothing
about which skill lives there, so it cannot be a row above. */}
{scanIssues.length > 0 ? (
<div className="min-w-0 border-t border-border/60 pt-3">
<SkillFreshnessScanIssues issues={scanIssues} />
</div>
) : null}
{run.state === 'error' ? (
<div className="space-y-2.5 rounded-md border border-destructive/35 bg-destructive/10 p-3">
<p className="text-[13px] font-medium text-foreground">

View File

@ -116,5 +116,8 @@ describe('groupSkillFreshness', () => {
expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo')
expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache')
expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current')
expect(chipFor(at('j', { status: 'unrecognized', topology: 'plugin-cache' }))).toBe(
'plugin-cache'
)
})
})

View File

@ -26,8 +26,9 @@ export type SkillFreshnessGroupModel = {
}
export function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null {
// Why: a location's own status wins over its topology — "the contents don't
// match" is more useful to the user than "it's a duplicate".
if (installation.status === 'unrecognized' && installation.topology === 'plugin-cache') {
return 'plugin-cache'
}
if (installation.status === 'unrecognized') {
return 'unrecognized'
}

View File

@ -0,0 +1,74 @@
import type { SkillFreshnessScanIssue } from '../../../../shared/skill-freshness'
import { translate } from '@/i18n/i18n'
function issueDescription(issue: SkillFreshnessScanIssue): string {
switch (issue.reason) {
case 'depth-limit':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanDepthLimit',
'Orca reached its plugin scan depth limit before checking this folder.'
)
case 'entry-limit':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanEntryLimit',
'Orca reached its plugin scan entry limit before checking the rest of this cache.'
)
case 'candidate-limit':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanCandidateLimit',
'Orca found more same-named skill folders than it can safely inspect.'
)
case 'manifest-limit':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanManifestLimit',
'Orca skipped this plugin manifest because it exceeded a safe limit.'
)
case 'outside-root':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanOutsideRoot',
'Orca skipped this plugin path because it points outside the plugin cache.'
)
case 'io-error':
return issue.errorCode
? translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanIoErrorWithCode',
'Orca could not read this plugin path ({{value0}}).',
{ value0: issue.errorCode }
)
: translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanIoError',
'Orca could not read this plugin path.'
)
case 'issue-limit':
return translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanIssueLimit',
'Orca found too many skipped plugin folders to list individually.'
)
}
}
export function SkillFreshnessScanIssues({
issues
}: {
issues: readonly SkillFreshnessScanIssue[]
}): React.JSX.Element {
return (
<>
{issues.map((issue) => (
<div
key={`${issue.rootId}\0${issue.path}\0${issue.reason}`}
className="space-y-1.5 py-3 first:pt-0 last:pb-0"
>
<p className="text-sm font-medium text-foreground">{issue.sourceLabel}</p>
<p className="text-xs leading-5 text-muted-foreground">{issueDescription(issue)}</p>
<span
className="block truncate font-mono text-[11px] text-muted-foreground"
title={issue.path}
>
{issue.path}
</span>
</div>
))}
</>
)
}

View File

@ -73,8 +73,6 @@ export function skippedReason(locations: readonly SkillLocationRow[]): string {
'auto.components.skills.SkillFreshnessRow.skippedReasonDuplicate',
'This is a separate copy, so the update wont reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.'
)
// Why: 'current' is non-blocking and an empty priority list is possible;
// both fall through to the generic skipped message.
case 'current':
case undefined:
return translate(

View File

@ -2,25 +2,42 @@ import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import { translate } from '@/i18n/i18n'
export type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention'
export type FreshnessSummaryKind =
| 'loading'
| 'empty'
| 'eligible'
| 'current'
| 'attention'
| 'scan-incomplete'
export function summarizeInventory(
inventory: SkillFreshnessInventory | null,
hasBlockedGroup: boolean
hasBlockedGroup: boolean,
hasIncompleteScan: boolean
): FreshnessSummaryKind {
if (!inventory) {
return 'loading'
}
if (inventory.installations.length === 0) {
return 'empty'
}
if (inventory.eligibleUpdateNames.length > 0) {
return 'eligible'
}
// Why: with nothing eligible, the modal is either genuinely all-clear or has
// out-of-date skills it can't safely update; the group filter already dropped
// the up-to-date and unrecognized-only noise, so a blocked group is the signal.
return hasBlockedGroup ? 'attention' : 'current'
// Why: a named skill the update can't converge outranks a coverage gap — the gap
// says something might be unchecked, the group says something definitely is wrong.
if (hasBlockedGroup) {
return 'attention'
}
// Why: a fault on the user's disk, or a bound that ended the walk early. Skipping a
// single folder is not either one — headlining that would put a permanent warning on
// any ordinary large plugin cache, the unclearable amber this change removes.
if (hasIncompleteScan) {
return 'scan-incomplete'
}
// Why: ordered after the scan checks so a scan that never completed is not reported
// as an empty machine — "none found" would be a claim the scan cannot support.
if (inventory.installations.length === 0) {
return 'empty'
}
return 'current'
}
/** Pre-run headline. Once a run starts, the dialog reports the run instead. */
@ -78,6 +95,19 @@ export function SummaryHeadline({
</div>
)
}
if (kind === 'scan-incomplete') {
// No follow-up sentence, matching 'attention': the skipped folders are listed
// directly under this headline, so pointing at a details panel would be stale.
return (
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.scanIncomplete',
'Orca could not finish checking plugin-managed skills.'
)}
</div>
)
}
return (
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">

View File

@ -25,7 +25,7 @@ function deferred<T>(): {
}
function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt }
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt }
}
let root: Root | null = null

View File

@ -3852,7 +3852,16 @@
"close": "Close",
"stop": "Stop",
"stopping": "Stopping…",
"stoppingHeadline": "Stopping the update…"
"stoppingHeadline": "Stopping the update…",
"scanIncomplete": "Orca could not finish checking plugin-managed skills.",
"scanDepthLimit": "Orca reached its plugin scan depth limit before checking this folder.",
"scanEntryLimit": "Orca reached its plugin scan entry limit before checking the rest of this cache.",
"scanCandidateLimit": "Orca found more same-named skill folders than it can safely inspect.",
"scanManifestLimit": "Orca skipped this plugin manifest because it exceeded a safe limit.",
"scanOutsideRoot": "Orca skipped this plugin path because it points outside the plugin cache.",
"scanIoErrorWithCode": "Orca could not read this plugin path ({{value0}}).",
"scanIoError": "Orca could not read this plugin path.",
"scanIssueLimit": "Orca found too many skipped plugin folders to list individually."
},
"SkillUpdateResultRows": {
"stillOutdated": "Still out of date after the update ran."

View File

@ -3829,7 +3829,16 @@
"done": "Done",
"stop": "Stop",
"stopping": "Stopping…",
"stoppingHeadline": "Stopping the update…"
"stoppingHeadline": "Stopping the update…",
"scanIncomplete": "Orca no pudo terminar de comprobar los skills gestionados por plugins.",
"scanDepthLimit": "Orca alcanzó el límite de profundidad del análisis antes de comprobar esta carpeta.",
"scanEntryLimit": "Orca alcanzó el límite de entradas del análisis antes de comprobar el resto de esta caché.",
"scanCandidateLimit": "Orca encontró más carpetas de skills con el mismo nombre de las que puede inspeccionar de forma segura.",
"scanManifestLimit": "Orca omitió este manifiesto del plugin porque superaba un límite seguro.",
"scanOutsideRoot": "Orca omitió esta ruta del plugin porque apunta fuera de la caché de plugins.",
"scanIoErrorWithCode": "Orca no pudo leer esta ruta del plugin ({{value0}}).",
"scanIoError": "Orca no pudo leer esta ruta del plugin.",
"scanIssueLimit": "Orca encontró demasiadas carpetas de plugins omitidas para mostrarlas por separado."
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Actualización disponible",

View File

@ -3829,7 +3829,16 @@
"done": "Done",
"stop": "Stop",
"stopping": "Stopping…",
"stoppingHeadline": "Stopping the update…"
"stoppingHeadline": "Stopping the update…",
"scanIncomplete": "Orca はプラグインで管理されるスキルの確認を完了できませんでした。",
"scanDepthLimit": "このフォルダーを確認する前に、プラグインスキャンの深度上限に達しました。",
"scanEntryLimit": "キャッシュの残りを確認する前に、プラグインスキャンのエントリ数上限に達しました。",
"scanCandidateLimit": "安全に確認できる数を超える同名のスキルフォルダーが見つかりました。",
"scanManifestLimit": "安全な上限を超えたため、このプラグインマニフェストをスキップしました。",
"scanOutsideRoot": "プラグインキャッシュの外部を指しているため、このプラグインパスをスキップしました。",
"scanIoErrorWithCode": "このプラグインパスを読み取れませんでした({{value0}})。",
"scanIoError": "このプラグインパスを読み取れませんでした。",
"scanIssueLimit": "スキップされたプラグインフォルダーが多すぎるため、個別に表示できません。"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "更新があります",

View File

@ -3829,7 +3829,16 @@
"done": "Done",
"stop": "Stop",
"stopping": "Stopping…",
"stoppingHeadline": "Stopping the update…"
"stoppingHeadline": "Stopping the update…",
"scanIncomplete": "Orca가 플러그인으로 관리되는 스킬 확인을 완료하지 못했습니다.",
"scanDepthLimit": "이 폴더를 확인하기 전에 플러그인 검사 깊이 제한에 도달했습니다.",
"scanEntryLimit": "이 캐시의 나머지 항목을 확인하기 전에 플러그인 검사 항목 수 제한에 도달했습니다.",
"scanCandidateLimit": "안전하게 검사할 수 있는 수보다 많은 동명 스킬 폴더가 발견되었습니다.",
"scanManifestLimit": "안전한 제한을 초과한 플러그인 매니페스트를 건너뛰었습니다.",
"scanOutsideRoot": "플러그인 캐시 외부를 가리키는 플러그인 경로를 건너뛰었습니다.",
"scanIoErrorWithCode": "이 플러그인 경로를 읽지 못했습니다({{value0}}).",
"scanIoError": "이 플러그인 경로를 읽지 못했습니다.",
"scanIssueLimit": "건너뛴 플러그인 폴더가 너무 많아 개별적으로 표시할 수 없습니다."
},
"SkillFreshnessStatusPill": {
"updateAvailable": "업데이트 가능",

View File

@ -3829,7 +3829,16 @@
"done": "Done",
"stop": "Stop",
"stopping": "Stopping…",
"stoppingHeadline": "Stopping the update…"
"stoppingHeadline": "Stopping the update…",
"scanIncomplete": "Orca 无法完成对插件管理技能的检查。",
"scanDepthLimit": "在检查此文件夹之前Orca 已达到插件扫描深度上限。",
"scanEntryLimit": "在检查此缓存的其余内容之前Orca 已达到插件扫描条目数上限。",
"scanCandidateLimit": "Orca 发现的同名技能文件夹数量超过了可安全检查的上限。",
"scanManifestLimit": "此插件清单超出安全限制Orca 已将其跳过。",
"scanOutsideRoot": "此插件路径指向插件缓存之外Orca 已将其跳过。",
"scanIoErrorWithCode": "Orca 无法读取此插件路径({{value0}})。",
"scanIoError": "Orca 无法读取此插件路径。",
"scanIssueLimit": "被跳过的插件文件夹过多Orca 无法逐一列出。"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "有可用更新",

View File

@ -57,7 +57,7 @@ function inventory(
installations: SkillFreshnessInstallation[],
eligibleUpdateNames: string[] = []
): SkillFreshnessInventory {
return { schemaVersion: 1, installations, eligibleUpdateNames, scannedAt: 1 }
return { schemaVersion: 1, installations, eligibleUpdateNames, scanIssues: [], scannedAt: 1 }
}
describe('getAgentSkillNavInstallStatus', () => {

View File

@ -2,12 +2,28 @@ import { describe, expect, it } from 'vitest'
import type {
SkillFreshnessInstallation,
SkillFreshnessInventory,
SkillFreshnessScanIssueReason,
SkillFreshnessStatus
} from '../../../shared/skill-freshness'
import { getSkillFreshnessDisplayStatus } from './skill-freshness-display-status'
import {
getSkillFreshnessDisplayStatus,
hasSkillCopyNeedingAttention
} from './skill-freshness-display-status'
const SKILL_NAME = 'orca-cli'
function scanIssue(
reason: SkillFreshnessScanIssueReason
): SkillFreshnessInventory['scanIssues'][number] {
return {
rootId: 'codex-plugin-cache',
sourceLabel: 'Codex plugin cache',
path: '/home/.codex/plugins/cache',
reason,
errorCode: reason === 'io-error' ? 'EACCES' : null
}
}
function placement(status: SkillFreshnessStatus, index = 0): SkillFreshnessInstallation {
return {
id: `${SKILL_NAME}-${index}`,
@ -31,11 +47,22 @@ function placement(status: SkillFreshnessStatus, index = 0): SkillFreshnessInsta
}
}
function pluginCachePlacement(
status: SkillFreshnessStatus = 'unrecognized'
): SkillFreshnessInstallation {
return {
...placement(status, 9),
topology: 'plugin-cache',
unresolvedPath: `/home/.codex/plugins/cache/openai-bundled/${SKILL_NAME}`
}
}
function inventory(
installations: SkillFreshnessInstallation[],
eligibleUpdateNames: string[] = []
eligibleUpdateNames: string[] = [],
scanIssues: SkillFreshnessInventory['scanIssues'] = []
): SkillFreshnessInventory {
return { schemaVersion: 1, installations, eligibleUpdateNames, scannedAt: 1 }
return { schemaVersion: 1, installations, eligibleUpdateNames, scanIssues, scannedAt: 1 }
}
describe('getSkillFreshnessDisplayStatus', () => {
@ -75,4 +102,93 @@ describe('getSkillFreshnessDisplayStatus', () => {
// all-clear over drift the update command cannot reach and the user cannot see.
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('needs-attention')
})
it.each([
['depth-limit'],
['entry-limit'],
['candidate-limit'],
['manifest-limit'],
['issue-limit'],
// Why: a vendor linking its skills out of the cache is a packaging choice, not a
// fault the user can clear — deleting the link only makes the package manager
// recreate it. Amber here is clean-on-main turned permanently amber.
['outside-root']
] as const)('does not report attention for the %s traversal bound', (reason) => {
// Why: these are Orca's own bounds. A large but healthy plugin cache would
// otherwise pin every skill amber with nothing the user could do about it.
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('current')], [], [scanIssue(reason)]),
SKILL_NAME
)
).toBe('up-to-date')
})
// Why: the only reason left that is a fact about the user's own disk rather than a
// bound Orca chose, so the only one a person can actually clear.
it('reports needs attention for the io-error scan fault', () => {
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('current')], [], [scanIssue('io-error')]),
SKILL_NAME
)
).toBe('needs-attention')
})
it('stays up to date beside another ecosystems same-name skill', () => {
// Why: the copy belongs to another tool, so no user action exists to clear it.
// Amber here is the badge-with-no-exit reported in #10633.
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('current'), pluginCachePlacement()]),
SKILL_NAME
)
).toBe('up-to-date')
expect(hasSkillCopyNeedingAttention(inventory([pluginCachePlacement()]), SKILL_NAME)).toBe(
false
)
})
it('still reports drift in our own copy when a plugin-managed one sits alongside', () => {
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('unrecognized'), pluginCachePlacement()]),
SKILL_NAME
)
).toBe('needs-attention')
})
it('reports attention when a plugin-cache copy is inaccessible', () => {
const value = inventory([pluginCachePlacement('inaccessible')])
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('needs-attention')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(true)
})
})
describe('hasSkillCopyNeedingAttention', () => {
it('ignores a traversal bound but keeps a real scan fault', () => {
expect(
hasSkillCopyNeedingAttention(
inventory([placement('current')], [], [scanIssue('entry-limit')]),
SKILL_NAME
)
).toBe(false)
expect(
hasSkillCopyNeedingAttention(
inventory([placement('current')], [], [scanIssue('io-error')]),
SKILL_NAME
)
).toBe(true)
})
// Why: an unreadable plugin path could hide a copy of anything, but a skill Orca
// never found anywhere is not the one to blame for it — that reads as a problem
// with a skill the user has not installed.
it('does not blame a skill with no placement for a fault elsewhere in the cache', () => {
const value = inventory([], [], [scanIssue('io-error')])
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false)
})
})

View File

@ -1,4 +1,5 @@
import {
isSkillScanIssueNeedingAttention,
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInventory
} from '../../../shared/skill-freshness'
@ -16,7 +17,6 @@ export function getSkillFreshnessDisplayStatus(
if (inventory?.eligibleUpdateNames.includes(skillName)) {
return 'update-available'
}
let hasPlacement = false
let hasBlockedCopy = false
for (const installation of inventory?.installations ?? []) {
@ -24,7 +24,10 @@ export function getSkillFreshnessDisplayStatus(
continue
}
hasPlacement = true
if (installation.status !== 'current') {
if (
installation.status !== 'current' &&
!(installation.status === 'unrecognized' && installation.topology === 'plugin-cache')
) {
hasBlockedCopy = true
}
}
@ -33,6 +36,12 @@ export function getSkillFreshnessDisplayStatus(
if (!hasPlacement) {
return 'installed'
}
// Why: an unreadable plugin path could hide a copy of any known skill, so it stays
// fail-closed — but only for skills Orca actually found somewhere. Flagging a skill
// that isn't installed at all blames it for a fault in someone else's plugin.
if (inventory?.scanIssues.some(isSkillScanIssueNeedingAttention)) {
return 'needs-attention'
}
// Why: no eligible update is not proof a copy is fine — it can equally mean a copy
// is out of date somewhere the update command cannot reach. Saying "Installed" there
// reads as all-clear and hides real drift, so that case gets its own attention state.
@ -48,14 +57,21 @@ export function hasSkillCopyNeedingAttention(
inventory: SkillFreshnessInventory | null,
skillName: string
): boolean {
return (inventory?.installations ?? []).some(
(installation) =>
installation.name === skillName &&
installation.status !== 'current' &&
// Why: an out-of-date copy the command converges is ordinary work, not a problem.
!(
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) &&
installation.status === 'outdated'
)
const placements = (inventory?.installations ?? []).filter(
(installation) => installation.name === skillName
)
return (
(placements.length > 0 &&
Boolean(inventory?.scanIssues.some(isSkillScanIssueNeedingAttention))) ||
placements.some(
(installation) =>
installation.status !== 'current' &&
!(installation.status === 'unrecognized' && installation.topology === 'plugin-cache') &&
// Why: an out-of-date copy the command converges is ordinary work, not a problem.
!(
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) &&
installation.status === 'outdated'
)
)
)
}

View File

@ -2807,6 +2807,7 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
schemaVersion: 1,
installations: [],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: Date.now()
}),
// Why: with no local skill homes there is nothing to update, so the run rail

View File

@ -84,10 +84,59 @@ export type SkillFreshnessInstallation = {
errorCategory: string | null
}
export type SkillFreshnessScanIssueReason =
| 'depth-limit'
| 'entry-limit'
| 'candidate-limit'
| 'manifest-limit'
| 'outside-root'
| 'io-error'
| 'issue-limit'
export type SkillFreshnessScanIssue = {
rootId: string
sourceLabel: string
path: string
reason: SkillFreshnessScanIssueReason
errorCode: string | null
}
// Why: a real read failure is a fact about the user's disk and stays actionable. Orca's
// own traversal bounds are not — reporting them as attention turns an ordinary large
// plugin cache into a permanent amber pill on every skill.
//
// 'outside-root' is deliberately NOT here. A plugin that links its skills out of the
// cache (a content-addressed store, a dev-linked package) is a packaging choice by the
// vendor, not a fault the user can clear: deleting the link only makes their package
// manager recreate it. Flagging it turned an install that is clean on main into amber on
// every installed skill. It is still listed in Details, like the other bounds.
const SKILL_SCAN_ATTENTION_REASONS = new Set<SkillFreshnessScanIssueReason>(['io-error'])
export function isSkillScanIssueNeedingAttention(issue: SkillFreshnessScanIssue): boolean {
return SKILL_SCAN_ATTENTION_REASONS.has(issue.reason)
}
// Why: these are the bounds that end the walk rather than skip one folder. They are
// still not the user's to act on, so they raise no pill — but a scan that stopped
// early cannot be reported as proof every copy is up to date.
const SKILL_SCAN_TRUNCATING_REASONS = new Set<SkillFreshnessScanIssueReason>([
'entry-limit',
'candidate-limit'
])
export function isTruncatingSkillScanReason(reason: SkillFreshnessScanIssueReason): boolean {
return SKILL_SCAN_TRUNCATING_REASONS.has(reason)
}
export function isSkillScanIssueTruncatingScan(issue: SkillFreshnessScanIssue): boolean {
return isTruncatingSkillScanReason(issue.reason)
}
export type SkillFreshnessInventory = {
schemaVersion: 1
installations: SkillFreshnessInstallation[]
eligibleUpdateNames: string[]
scanIssues: SkillFreshnessScanIssue[]
scannedAt: number
}