Skip to content

Commit f22d64e

Browse files
committed
fix(skill): guard skill_view fs errors
1 parent 11f3a35 commit f22d64e

File tree

2 files changed

+281
-52
lines changed

2 files changed

+281
-52
lines changed

src/main/presenter/skillPresenter/index.ts

Lines changed: 106 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ const BINARY_LIKE_EXTENSIONS = new Set([
103103
const DRAFT_ALLOWED_TOP_LEVEL_DIRS = new Set(['references', 'templates', 'scripts', 'assets'])
104104
const DRAFT_CONVERSATION_ID_PATTERN = /^[A-Za-z0-9._-]+$/
105105
const DRAFT_ID_PATTERN = /^[A-Za-z0-9._-]+$/
106+
const DRAFT_ACTIVITY_MARKER = '.lastActivity'
106107
const DRAFT_INJECTION_PATTERNS = [
107108
/ignore\s+previous\s+instructions/i,
108109
/disregard\s+all\s+prior/i,
@@ -493,82 +494,106 @@ export class SkillPresenter implements ISkillPresenter {
493494
const isPinned = pinnedSkills.includes(metadata.name)
494495

495496
if (options?.filePath?.trim()) {
496-
const resolvedPath = this.resolveSkillRelativePath(
497-
metadata.skillRoot,
498-
options.filePath.trim()
499-
)
500-
if (!resolvedPath) {
501-
return {
502-
success: false,
503-
error: 'Requested skill file is outside the skill root'
497+
try {
498+
const requestedFilePath = options.filePath.trim()
499+
const resolvedPath = this.resolveSkillRelativePath(metadata.skillRoot, requestedFilePath)
500+
if (!resolvedPath) {
501+
return {
502+
success: false,
503+
error: 'Requested skill file is outside the skill root'
504+
}
504505
}
505-
}
506506

507-
if (!fs.existsSync(resolvedPath)) {
508-
return {
509-
success: false,
510-
error: `Skill file not found: ${options.filePath.trim()}`
507+
if (!fs.existsSync(resolvedPath)) {
508+
return {
509+
success: false,
510+
error: `Skill file not found: ${requestedFilePath}`
511+
}
512+
}
513+
514+
const stats = fs.statSync(resolvedPath)
515+
if (!stats.isFile()) {
516+
return {
517+
success: false,
518+
error: 'Requested skill path is not a file'
519+
}
520+
}
521+
if (stats.size > SKILL_CONFIG.MAX_LINKED_FILE_SIZE) {
522+
return {
523+
success: false,
524+
error: 'Requested skill file is too large to load inline'
525+
}
526+
}
527+
if (this.isBinaryLikeFile(resolvedPath)) {
528+
return {
529+
success: false,
530+
error: 'Binary skill files cannot be loaded with skill_view'
531+
}
511532
}
512-
}
513533

514-
const stats = fs.statSync(resolvedPath)
515-
if (!stats.isFile()) {
516534
return {
517-
success: false,
518-
error: 'Requested skill path is not a file'
535+
success: true,
536+
name: metadata.name,
537+
category: metadata.category ?? null,
538+
skillRoot: metadata.skillRoot,
539+
filePath: path.relative(metadata.skillRoot, resolvedPath),
540+
content: fs.readFileSync(resolvedPath, 'utf-8'),
541+
platforms: metadata.platforms,
542+
metadata: metadata.metadata,
543+
isPinned
519544
}
520-
}
521-
if (stats.size > SKILL_CONFIG.MAX_LINKED_FILE_SIZE) {
545+
} catch (error) {
546+
const errorMessage = error instanceof Error ? error.message : String(error)
547+
console.error('[SkillPresenter] Failed to load requested skill file for skill_view:', {
548+
name: metadata.name,
549+
filePath: options.filePath.trim(),
550+
error
551+
})
522552
return {
523553
success: false,
524-
error: 'Requested skill file is too large to load inline'
554+
error: `Failed to load requested skill file: ${errorMessage}`
525555
}
526556
}
527-
if (this.isBinaryLikeFile(resolvedPath)) {
557+
}
558+
559+
try {
560+
const stats = fs.statSync(metadata.path)
561+
if (stats.size > SKILL_CONFIG.SKILL_FILE_MAX_SIZE) {
562+
const errorMessage = `[SkillPresenter] Skill file too large: ${stats.size} bytes (max: ${SKILL_CONFIG.SKILL_FILE_MAX_SIZE})`
563+
console.error(errorMessage)
528564
return {
529565
success: false,
530-
error: 'Binary skill files cannot be loaded with skill_view'
566+
error: errorMessage
531567
}
532568
}
533569

570+
const rawContent = fs.readFileSync(metadata.path, 'utf-8')
571+
const { content } = matter(rawContent)
572+
534573
return {
535574
success: true,
536575
name: metadata.name,
537576
category: metadata.category ?? null,
538577
skillRoot: metadata.skillRoot,
539-
filePath: path.relative(metadata.skillRoot, resolvedPath),
540-
content: fs.readFileSync(resolvedPath, 'utf-8'),
578+
filePath: null,
579+
content: this.replacePathVariables(content, metadata),
541580
platforms: metadata.platforms,
542581
metadata: metadata.metadata,
582+
linkedFiles: this.listSkillLinkedFiles(metadata.skillRoot),
543583
isPinned
544584
}
545-
}
546-
547-
const stats = fs.statSync(metadata.path)
548-
if (stats.size > SKILL_CONFIG.SKILL_FILE_MAX_SIZE) {
549-
const errorMessage = `[SkillPresenter] Skill file too large: ${stats.size} bytes (max: ${SKILL_CONFIG.SKILL_FILE_MAX_SIZE})`
550-
console.error(errorMessage)
585+
} catch (error) {
586+
const errorMessage = error instanceof Error ? error.message : String(error)
587+
console.error('[SkillPresenter] Failed to load skill_view content:', {
588+
name: metadata.name,
589+
path: metadata.path,
590+
error
591+
})
551592
return {
552593
success: false,
553-
error: errorMessage
594+
error: `Failed to load skill view: ${errorMessage}`
554595
}
555596
}
556-
557-
const rawContent = fs.readFileSync(metadata.path, 'utf-8')
558-
const { content } = matter(rawContent)
559-
560-
return {
561-
success: true,
562-
name: metadata.name,
563-
category: metadata.category ?? null,
564-
skillRoot: metadata.skillRoot,
565-
filePath: null,
566-
content: this.replacePathVariables(content, metadata),
567-
platforms: metadata.platforms,
568-
metadata: metadata.metadata,
569-
linkedFiles: this.listSkillLinkedFiles(metadata.skillRoot),
570-
isPinned
571-
}
572597
}
573598

574599
async manageDraftSkill(
@@ -586,6 +611,7 @@ export class SkillPresenter implements ISkillPresenter {
586611
}
587612
const { draftId, draftPath } = this.createDraftHandle(conversationId)
588613
this.atomicWriteFile(path.join(draftPath, 'SKILL.md'), request.content!)
614+
this.touchDraftActivity(draftPath)
589615
return { success: true, action, draftId, skillName: parsed.skillName }
590616
}
591617
case 'edit': {
@@ -613,6 +639,7 @@ export class SkillPresenter implements ISkillPresenter {
613639
return { success: false, action, error: 'Draft not found' }
614640
}
615641
this.atomicWriteFile(path.join(draftPath, 'SKILL.md'), request.content!)
642+
this.touchDraftActivity(draftPath)
616643
return { success: true, action, draftId, skillName: parsed.skillName }
617644
}
618645
case 'write_file': {
@@ -656,6 +683,7 @@ export class SkillPresenter implements ISkillPresenter {
656683
}
657684
fs.mkdirSync(path.dirname(resolvedFilePath), { recursive: true })
658685
this.atomicWriteFile(resolvedFilePath, request.fileContent)
686+
this.touchDraftActivity(draftPath)
659687
return {
660688
success: true,
661689
action,
@@ -695,6 +723,7 @@ export class SkillPresenter implements ISkillPresenter {
695723
return { success: false, action, error: 'Draft file not found' }
696724
}
697725
fs.rmSync(resolvedFilePath, { force: true })
726+
this.touchDraftActivity(draftPath)
698727
return {
699728
success: true,
700729
action,
@@ -1733,7 +1762,17 @@ export class SkillPresenter implements ISkillPresenter {
17331762
return acc
17341763
}
17351764

1736-
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
1765+
let entries: fs.Dirent[]
1766+
try {
1767+
entries = fs.readdirSync(currentDir, { withFileTypes: true })
1768+
} catch (error) {
1769+
logger.warn('[SkillPresenter] Failed to scan skill directory, skipping subtree', {
1770+
currentDir,
1771+
error
1772+
})
1773+
return acc
1774+
}
1775+
17371776
for (const entry of entries) {
17381777
if (entry.isSymbolicLink()) {
17391778
continue
@@ -1980,6 +2019,22 @@ export class SkillPresenter implements ISkillPresenter {
19802019
return resolvedPath
19812020
}
19822021

2022+
private getDraftActivityMarkerPath(draftPath: string): string {
2023+
return path.join(draftPath, DRAFT_ACTIVITY_MARKER)
2024+
}
2025+
2026+
private touchDraftActivity(draftPath: string): void {
2027+
fs.writeFileSync(this.getDraftActivityMarkerPath(draftPath), `${Date.now()}`, 'utf-8')
2028+
}
2029+
2030+
private getDraftLastActivityMs(draftPath: string): number {
2031+
const markerPath = this.getDraftActivityMarkerPath(draftPath)
2032+
if (fs.existsSync(markerPath)) {
2033+
return fs.statSync(markerPath).mtimeMs
2034+
}
2035+
return fs.statSync(draftPath).mtimeMs
2036+
}
2037+
19832038
private atomicWriteFile(targetPath: string, content: string): void {
19842039
const tempPath = path.join(
19852040
path.dirname(targetPath),
@@ -2009,8 +2064,8 @@ export class SkillPresenter implements ISkillPresenter {
20092064
}
20102065

20112066
const draftDir = path.join(conversationDir, draftEntry.name)
2012-
const stats = fs.statSync(draftDir)
2013-
if (now - stats.mtimeMs > SKILL_CONFIG.DRAFT_RETENTION_MS) {
2067+
const lastActivityMs = this.getDraftLastActivityMs(draftDir)
2068+
if (now - lastActivityMs > SKILL_CONFIG.DRAFT_RETENTION_MS) {
20142069
fs.rmSync(draftDir, { recursive: true, force: true })
20152070
}
20162071
}

0 commit comments

Comments
 (0)