feat(#824): protect installer skill links
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -161,6 +161,7 @@ link_targets=(
|
||||
)
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
@@ -203,7 +204,14 @@ link_skill_into_target() {
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
local raw_target resolved_target
|
||||
raw_target="$(readlink "$link_path")"
|
||||
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
|
||||
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
else
|
||||
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
|
||||
|
||||
$linkPath = Join-Path $TargetDir $name
|
||||
|
||||
# Already a junction/symlink — recreate
|
||||
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
$rawTarget = @($existing.Target)[0]
|
||||
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
|
||||
$rawTarget
|
||||
}
|
||||
else {
|
||||
Join-Path (Split-Path $linkPath -Parent) $rawTarget
|
||||
}
|
||||
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
|
||||
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
|
||||
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
|
||||
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $owned) {
|
||||
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
|
||||
return
|
||||
}
|
||||
Remove-Item $linkPath -Force
|
||||
}
|
||||
elseif ($existing) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
@@ -21,6 +23,10 @@ import {
|
||||
type SkillPaths,
|
||||
} from './skill.js';
|
||||
|
||||
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
||||
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
||||
);
|
||||
|
||||
describe('Claude skill bridge', () => {
|
||||
let root: string;
|
||||
let paths: SkillPaths;
|
||||
@@ -235,6 +241,33 @@ describe('Claude skill bridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install linker compatibility', () => {
|
||||
it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
|
||||
createSkill('dangling-foreign');
|
||||
createSkill('live-foreign');
|
||||
createSkill('missing');
|
||||
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
const liveLink = join(paths.claudeSkillsDir, 'live-foreign');
|
||||
const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign');
|
||||
symlinkSync(external, liveLink);
|
||||
symlinkSync(join(root, 'external-missing'), danglingLink);
|
||||
|
||||
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') },
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(readlinkSync(liveLink)).toBe(external);
|
||||
expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
|
||||
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
|
||||
join(paths.mosaicSkillsDir, 'missing'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncClaudeSkills', () => {
|
||||
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
||||
createSkill('added-after-setup');
|
||||
|
||||
Reference in New Issue
Block a user