diff --git a/src/agents/sessions/package-manager.test.ts b/src/agents/sessions/package-manager.test.ts index 577e3f93083..29a3d8a7fb5 100644 --- a/src/agents/sessions/package-manager.test.ts +++ b/src/agents/sessions/package-manager.test.ts @@ -120,6 +120,50 @@ describe("DefaultPackageManager", () => { ); }); + it("keeps auto-discovered project resources inside their resource roots", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const configRoot = join(root, ".openclaw"); + const outsideRoot = join(root, "outside"); + const insidePrompt = join(configRoot, "prompts", "inside.md"); + const insideTheme = join(configRoot, "themes", "inside.json"); + const insideExtension = join(configRoot, "extensions", "inside.ts"); + await mkdir(join(root, ".git")); + await mkdir(join(configRoot, "prompts"), { recursive: true }); + await mkdir(join(configRoot, "themes"), { recursive: true }); + await mkdir(join(configRoot, "extensions"), { recursive: true }); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(insidePrompt, "# Inside\n", "utf-8"); + await writeFile(insideTheme, "{}\n", "utf-8"); + await writeFile(insideExtension, "export default {};\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.md"), "# Outside\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.json"), "{}\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.ts"), "export default {};\n", "utf-8"); + + try { + await symlink(join(outsideRoot, "outside.md"), join(configRoot, "prompts", "linked.md")); + await symlink(join(outsideRoot, "outside.json"), join(configRoot, "themes", "linked.json")); + await symlink(join(outsideRoot, "outside.ts"), join(configRoot, "extensions", "linked.ts")); + await symlink(outsideRoot, join(configRoot, "extensions", "linked-dir"), "dir"); + } catch { + // Some filesystems disallow symlinks; the inside assertions still prove discovery. + } + + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({}), + }); + + const resolved = await manager.resolve(); + + expect(resolved.prompts.map((prompt) => prompt.path)).toContain(insidePrompt); + expect(resolved.themes.map((theme) => theme.path)).toContain(insideTheme); + expect(resolved.extensions.map((extension) => extension.path)).toContain(insideExtension); + expect(resolved.prompts.some((prompt) => prompt.path.includes("linked"))).toBe(false); + expect(resolved.themes.some((theme) => theme.path.includes("linked"))).toBe(false); + expect(resolved.extensions.some((extension) => extension.path.includes("linked"))).toBe(false); + }); + it("does not auto-install missing npm package resources", async () => { const root = await makeTempDir("openclaw-package-manager-"); const manager = new DefaultPackageManager({ diff --git a/src/agents/sessions/package-manager.ts b/src/agents/sessions/package-manager.ts index 543e862d800..7a1a122733e 100644 --- a/src/agents/sessions/package-manager.ts +++ b/src/agents/sessions/package-manager.ts @@ -422,6 +422,9 @@ function collectAutoPromptEntries(dir: string): string[] { } const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { @@ -467,6 +470,9 @@ function collectAutoThemeEntries(dir: string): string[] { } const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { @@ -502,7 +508,7 @@ function readResourceManifestFile(packageJsonPath: string): ResourceManifest | n } } -function resolveExtensionEntries(dir: string): string[] | null { +function resolveExtensionEntries(dir: string, rootDir = dir): string[] | null { const packageJsonPath = join(dir, "package.json"); if (existsSync(packageJsonPath)) { const manifest = readResourceManifestFile(packageJsonPath); @@ -510,7 +516,7 @@ function resolveExtensionEntries(dir: string): string[] | null { const entries: string[] = []; for (const extPath of manifest.extensions) { const resolvedExtPath = resolve(dir, extPath); - if (existsSync(resolvedExtPath)) { + if (existsSync(resolvedExtPath) && isRealPathWithinRoot(rootDir, resolvedExtPath)) { entries.push(resolvedExtPath); } } @@ -522,10 +528,10 @@ function resolveExtensionEntries(dir: string): string[] | null { const indexTs = join(dir, "index.ts"); const indexJs = join(dir, "index.js"); - if (existsSync(indexTs)) { + if (existsSync(indexTs) && isRealPathWithinRoot(rootDir, indexTs)) { return [indexTs]; } - if (existsSync(indexJs)) { + if (existsSync(indexJs) && isRealPathWithinRoot(rootDir, indexJs)) { return [indexJs]; } @@ -559,6 +565,9 @@ function collectAutoExtensionEntries(dir: string): string[] { } const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } let isDir = entry.isDirectory(); let isFile = entry.isFile(); @@ -581,7 +590,7 @@ function collectAutoExtensionEntries(dir: string): string[] { if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { entries.push(fullPath); } else if (isDir) { - const resolvedEntries = resolveExtensionEntries(fullPath); + const resolvedEntries = resolveExtensionEntries(fullPath, dir); if (resolvedEntries) { entries.push(...resolvedEntries); } @@ -622,7 +631,10 @@ function isPathWithinRoot(root: string, candidate: string): boolean { } function isRealPathWithinRoot(root: string, candidate: string): boolean { - return isPathWithinRoot(resolveRealPathIfPossible(resolve(root)), resolveRealPathIfPossible(candidate)); + return isPathWithinRoot( + resolveRealPathIfPossible(resolve(root)), + resolveRealPathIfPossible(candidate), + ); } function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {