From a983332016b8f0572cceb8575516ff37178c5c9b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 22:15:33 -0400 Subject: [PATCH] refactor(web-server): modularize shared-routes and Windows-aware symlink status --- src/web-server/shared-routes-collections.ts | 192 ++++ src/web-server/shared-routes-content.ts | 157 +++ .../shared-routes-markdown-walker.ts | 90 ++ src/web-server/shared-routes-markdown.ts | 163 +++ src/web-server/shared-routes-path-guards.ts | 77 ++ .../shared-routes-plugin-registry-content.ts | 103 ++ src/web-server/shared-routes-plugins.ts | 199 ++++ .../shared-routes-symlink-status.ts | 144 +++ src/web-server/shared-routes-types.ts | 32 + src/web-server/shared-routes.ts | 947 +----------------- .../shared-routes-symlink-status.test.ts | 170 ++++ 11 files changed, 1348 insertions(+), 926 deletions(-) create mode 100644 src/web-server/shared-routes-collections.ts create mode 100644 src/web-server/shared-routes-content.ts create mode 100644 src/web-server/shared-routes-markdown-walker.ts create mode 100644 src/web-server/shared-routes-markdown.ts create mode 100644 src/web-server/shared-routes-path-guards.ts create mode 100644 src/web-server/shared-routes-plugin-registry-content.ts create mode 100644 src/web-server/shared-routes-plugins.ts create mode 100644 src/web-server/shared-routes-symlink-status.ts create mode 100644 src/web-server/shared-routes-types.ts create mode 100644 tests/unit/web-server/shared-routes-symlink-status.test.ts diff --git a/src/web-server/shared-routes-collections.ts b/src/web-server/shared-routes-collections.ts new file mode 100644 index 00000000..fa73a423 --- /dev/null +++ b/src/web-server/shared-routes-collections.ts @@ -0,0 +1,192 @@ +/** + * Shared Routes — Collection item list builders + * + * Builds SharedItem arrays for commands, skills, and agents with + * a short-lived in-memory cache keyed by collection type. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { getCcsDir } from '../config/config-loader-facade'; +import { safeRealPath, isPathWithinAny, resolveAllowedRoots } from './shared-routes-path-guards'; +import { + readMarkdownDescription, + readFirstMarkdownDescription, + collectMarkdownFiles, +} from './shared-routes-markdown'; +import { getPluginItems } from './shared-routes-plugins'; +import type { + SharedItem, + SharedItemsCacheEntry, + SharedCollectionType, +} from './shared-routes-types'; + +const SHARED_ITEMS_CACHE_TTL_MS = 1000; + +const sharedItemsCache = new Map(); + +// --------------------------------------------------------------------------- +// Per-type item builders +// --------------------------------------------------------------------------- + +export function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); + const items: SharedItem[] = []; + + for (const markdownFile of markdownFiles) { + const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots); + if (!description) { + continue; + } + + const relativePath = path.relative(sharedDir, markdownFile.displayPath); + const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, ''); + if (!normalizedName) { + continue; + } + + items.push({ + name: normalizedName, + description, + path: markdownFile.displayPath, + type: 'command', + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getSkillOrAgentItem( + type: 'skills' | 'agents', + entry: fs.Dirent, + entryPath: string, + resolvedEntryPath: string, + allowedRoots: Set, + stats: fs.Stats +): SharedItem | null { + if (type === 'skills') { + if (!stats.isDirectory()) { + return null; + } + const description = readMarkdownDescription( + path.join(resolvedEntryPath, 'SKILL.md'), + allowedRoots + ); + if (!description) { + return null; + } + return { name: entry.name, description, path: entryPath, type: 'skill' }; + } + + // agents + if (stats.isDirectory()) { + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'prompt.md'), + path.join(resolvedEntryPath, 'AGENT.md'), + path.join(resolvedEntryPath, 'agent.md'), + ], + allowedRoots + ); + if (!description) { + return null; + } + return { name: entry.name, description, path: entryPath, type: 'agent' }; + } + + if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) { + return null; + } + + const description = readMarkdownDescription(resolvedEntryPath, allowedRoots); + if (!description) { + return null; + } + return { name: entry.name.replace(/\.md$/i, ''), description, path: entryPath, type: 'agent' }; +} + +// --------------------------------------------------------------------------- +// Public cache-backed entry point +// --------------------------------------------------------------------------- + +export function getSharedItems(type: SharedCollectionType): SharedItem[] { + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', type); + const now = Date.now(); + + if (!fs.existsSync(sharedDir)) { + sharedItemsCache.delete(type); + return []; + } + + const cached = sharedItemsCache.get(type); + if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) { + return cached.items; + } + + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot); + + if (type === 'commands') { + const commandItems = getCommandItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: commandItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return commandItems; + } + + if (type === 'plugins') { + const pluginItems = getPluginItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: pluginItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return pluginItems; + } + + // skills | agents + const items: SharedItem[] = []; + try { + const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); + + for (const entry of entries) { + try { + const entryPath = path.join(sharedDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + const stats = fs.statSync(resolvedEntryPath); + const item = getSkillOrAgentItem( + type, + entry, + entryPath, + resolvedEntryPath, + allowedRoots, + stats + ); + if (!item) { + continue; + } + items.push(item); + } catch { + // Fail soft per entry so one bad item does not hide valid results. + } + } + } catch { + // Directory read failed — return empty list + } + + const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name)); + sharedItemsCache.set(type, { + items: sortedItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return sortedItems; +} diff --git a/src/web-server/shared-routes-content.ts b/src/web-server/shared-routes-content.ts new file mode 100644 index 00000000..705497b9 --- /dev/null +++ b/src/web-server/shared-routes-content.ts @@ -0,0 +1,157 @@ +/** + * Shared Routes — Item content readers + * + * Resolves the full markdown content for a given shared item path, + * routing by collection type and enforcing allowed-root checks. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; +import { + readMarkdownContent, + resolveReadableMarkdownPath, + MAX_CONTENT_FILE_BYTES, +} from './shared-routes-markdown'; +import { isPluginInfrastructurePath } from './shared-routes-plugins'; +import { getPluginRegistryContent } from './shared-routes-plugin-registry-content'; +import type { SharedContentType } from './shared-routes-types'; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export function getSharedSettingsContent( + itemPath: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + if (path.basename(itemPath) !== 'settings.json') { + return null; + } + + const sharedRoot = Array.from(allowedRoots)[0]; + if (!sharedRoot) { + return null; + } + + const settingsPath = path.join(sharedRoot, 'settings.json'); + const resolvedSettingsPath = safeRealPath(settingsPath); + if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) { + return null; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedSettingsPath); + } catch { + return null; + } + + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + try { + const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8'); + const parsed = JSON.parse(rawContent) as unknown; + return { + content: JSON.stringify(parsed, null, 2), + contentPath: resolvedSettingsPath, + }; + } catch { + const content = readMarkdownContent(resolvedSettingsPath, allowedRoots); + return content ? { content, contentPath: resolvedSettingsPath } : null; + } +} + +// --------------------------------------------------------------------------- +// Generic item content +// --------------------------------------------------------------------------- + +export function getSharedItemContent( + type: SharedContentType, + itemPath: string, + allowedRoots: Set, + sharedDir: string +): { content: string; contentPath: string } | null { + if (type === 'settings') { + return getSharedSettingsContent(itemPath, allowedRoots); + } + + if (type === 'plugins') { + const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots); + if (pluginRegistryContent) { + return pluginRegistryContent; + } + } + + const resolvedItemPath = safeRealPath(itemPath); + if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { + return null; + } + + let itemStats: fs.Stats; + try { + itemStats = fs.statSync(resolvedItemPath); + } catch { + return null; + } + + let markdownPath: string | null = null; + + if (type === 'commands') { + if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) { + return null; + } + markdownPath = resolvedItemPath; + } else if (type === 'skills') { + if (!itemStats.isDirectory()) { + return null; + } + markdownPath = resolveReadableMarkdownPath( + [path.join(resolvedItemPath, 'SKILL.md')], + allowedRoots + ); + } else if (type === 'plugins') { + if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) { + return null; + } + markdownPath = resolveReadableMarkdownPath( + [ + path.join(resolvedItemPath, 'README.md'), + path.join(resolvedItemPath, 'readme.md'), + path.join(resolvedItemPath, 'PLUGIN.md'), + ], + allowedRoots + ); + if (!markdownPath) { + return null; + } + } else { + // agents + if (itemStats.isDirectory()) { + markdownPath = resolveReadableMarkdownPath( + [ + path.join(resolvedItemPath, 'prompt.md'), + path.join(resolvedItemPath, 'AGENT.md'), + path.join(resolvedItemPath, 'agent.md'), + ], + allowedRoots + ); + } else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) { + markdownPath = resolvedItemPath; + } + } + + if (!markdownPath) { + return null; + } + + const content = readMarkdownContent(markdownPath, allowedRoots); + if (!content) { + return null; + } + + return { content, contentPath: markdownPath }; +} diff --git a/src/web-server/shared-routes-markdown-walker.ts b/src/web-server/shared-routes-markdown-walker.ts new file mode 100644 index 00000000..3ba1e545 --- /dev/null +++ b/src/web-server/shared-routes-markdown-walker.ts @@ -0,0 +1,90 @@ +/** + * Shared Routes — Markdown directory walker + * + * Iterative BFS/DFS over a shared directory collecting all .md files + * within allowed roots and the configured traversal depth limit. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; + +const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; + +export interface MarkdownFileEntry { + displayPath: string; + resolvedPath: string; +} + +export function collectMarkdownFiles( + sharedDir: string, + allowedRoots: Set +): MarkdownFileEntry[] { + const directoriesToVisit: Array<{ path: string; depth: number }> = [ + { path: sharedDir, depth: 0 }, + ]; + const visitedDirectories = new Set(); + const markdownFiles: MarkdownFileEntry[] = []; + + while (directoriesToVisit.length > 0) { + const current = directoriesToVisit.pop(); + if (!current) { + continue; + } + + const currentDir = current.path; + const resolvedCurrentDir = safeRealPath(currentDir); + if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { + continue; + } + + const normalizedDirPath = + process.platform === 'win32' + ? path.resolve(resolvedCurrentDir).toLowerCase() + : path.resolve(resolvedCurrentDir); + + if (visitedDirectories.has(normalizedDirPath)) { + continue; + } + visitedDirectories.add(normalizedDirPath); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedEntryPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) { + directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 }); + } + continue; + } + + if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push({ + displayPath: entryPath, + resolvedPath: resolvedEntryPath, + }); + } + } + } + + return markdownFiles; +} diff --git a/src/web-server/shared-routes-markdown.ts b/src/web-server/shared-routes-markdown.ts new file mode 100644 index 00000000..8887ca66 --- /dev/null +++ b/src/web-server/shared-routes-markdown.ts @@ -0,0 +1,163 @@ +/** + * Shared Routes — Markdown file reading and description extraction + * + * Handles YAML frontmatter parsing, body-line extraction, and safe + * bounded reads for both description snippets and full content. + * + * Directory walking is in shared-routes-markdown-walker.ts. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; + +const MAX_DESCRIPTION_LENGTH = 140; +const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB + +/** Exported for content module which uses a larger limit. */ +export const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB + +// Re-export walker types so callers only need one import for markdown concerns. +export type { MarkdownFileEntry } from './shared-routes-markdown-walker'; +export { collectMarkdownFiles } from './shared-routes-markdown-walker'; + +// --------------------------------------------------------------------------- +// Description extraction helpers +// --------------------------------------------------------------------------- + +function isDescriptionBodyLine(line: string): boolean { + if (!line) { + return false; + } + if (line === '---' || line === '...') { + return false; + } + return !line.startsWith('#') && !line.startsWith('