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")"
|
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.
|
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||||
@@ -203,7 +204,14 @@ link_skill_into_target() {
|
|||||||
link_path="$target_dir/$name"
|
link_path="$target_dir/$name"
|
||||||
|
|
||||||
if [[ -L "$link_path" ]]; then
|
if [[ -L "$link_path" ]]; then
|
||||||
ln -sfn "$skill_path" "$link_path"
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
|
|||||||
|
|
||||||
$linkPath = Join-Path $TargetDir $name
|
$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
|
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
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
|
Remove-Item $linkPath -Force
|
||||||
}
|
}
|
||||||
elseif ($existing) {
|
elseif ($existing) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
@@ -21,6 +23,10 @@ import {
|
|||||||
type SkillPaths,
|
type SkillPaths,
|
||||||
} from './skill.js';
|
} from './skill.js';
|
||||||
|
|
||||||
|
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
||||||
|
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
describe('Claude skill bridge', () => {
|
describe('Claude skill bridge', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
let paths: SkillPaths;
|
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', () => {
|
describe('syncClaudeSkills', () => {
|
||||||
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
||||||
createSkill('added-after-setup');
|
createSkill('added-after-setup');
|
||||||
|
|||||||
Reference in New Issue
Block a user