Skip to content

Commit 926bf66

Browse files
committed
fix(skills): sync managed symlink skills as directories
1 parent cca4f3c commit 926bf66

3 files changed

Lines changed: 105 additions & 6 deletions

File tree

src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
5-
import { withEnv } from "../test-utils/env.js";
5+
import { withEnv, withEnvAsync } from "../test-utils/env.js";
66
import { writeSkill } from "./skills.e2e-test-helpers.js";
77
import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills/workspace.js";
88

@@ -357,4 +357,45 @@ describe("buildWorkspaceSkillsPrompt", () => {
357357
true,
358358
);
359359
});
360+
361+
it("syncs managed symlinked skills as real directories in the target workspace", async () => {
362+
const sourceWorkspace = await createCaseDir("source");
363+
const targetWorkspace = await createCaseDir("target");
364+
const managedDir = path.join(sourceWorkspace, ".managed");
365+
const skillName = "managed-linked";
366+
const targetSkillDir = path.join(await createCaseDir("manager-cache"), ".hidden-target");
367+
await writeSkill({
368+
dir: targetSkillDir,
369+
name: skillName,
370+
description: "Managed symlink target",
371+
});
372+
await fs.mkdir(managedDir, { recursive: true });
373+
await fs.symlink(
374+
targetSkillDir,
375+
path.join(managedDir, skillName),
376+
process.platform === "win32" ? "junction" : "dir",
377+
);
378+
379+
await withEnvAsync({ HOME: sourceWorkspace }, () =>
380+
syncSkillsToWorkspace({
381+
sourceWorkspaceDir: sourceWorkspace,
382+
targetWorkspaceDir: targetWorkspace,
383+
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
384+
managedSkillsDir: managedDir,
385+
skillFilter: [skillName],
386+
}),
387+
);
388+
389+
const syncedSkillDir = path.join(targetWorkspace, "skills", skillName);
390+
expect(await pathExists(path.join(syncedSkillDir, "SKILL.md"))).toBe(true);
391+
expect((await fs.lstat(syncedSkillDir)).isSymbolicLink()).toBe(false);
392+
expect(await pathExists(path.join(targetWorkspace, "skills", ".hidden-target"))).toBe(false);
393+
expect(
394+
buildWorkspaceSkillsPrompt(targetWorkspace, {
395+
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
396+
managedSkillsDir: path.join(targetWorkspace, ".managed"),
397+
skillFilter: [skillName],
398+
}),
399+
).toContain("Managed symlink target");
400+
});
360401
});

src/agents/skills/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export type SkillEntry = {
8181
metadata?: OpenClawSkillMetadata;
8282
invocation?: SkillInvocationPolicy;
8383
exposure?: SkillExposure;
84+
syncSourceDir?: string;
85+
syncDirName?: string;
8486
};
8587

8688
export type SkillEligibilityContext = {

src/agents/skills/workspace.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,13 @@ type ResolvedSkillsLimits = {
154154
type LoadedSkillRecord = {
155155
skill: Skill;
156156
frontmatter?: ParsedSkillFrontmatter;
157+
syncSourceDir?: string;
158+
syncDirName?: string;
157159
};
158160

159161
type CandidateSkillDir = {
160162
skillDir: string;
163+
skillDirRealPath: string;
161164
name: string;
162165
skillMdRealPath: string;
163166
};
@@ -388,16 +391,53 @@ function loadContainedSkillRecords(params: {
388391
skillDir: string;
389392
source: string;
390393
maxSkillFileBytes: number;
394+
canonicalSkillDir?: string;
391395
}): LoadedSkillRecord[] {
392396
const expectedBaseDir = path.resolve(params.skillDir);
393397
const loaded = loadSkillsFromDirSafe({
394398
dir: params.skillDir,
395399
source: params.source,
396400
maxBytes: params.maxSkillFileBytes,
397401
});
398-
return unwrapLoadedSkillRecords(loaded).filter(
402+
const records = unwrapLoadedSkillRecords(loaded).filter(
399403
(record) => path.resolve(record.skill.baseDir) === expectedBaseDir,
400404
);
405+
const canonicalSkillDir = params.canonicalSkillDir;
406+
return canonicalSkillDir
407+
? records.map((record) => canonicalizeLoadedSkillRecord(record, canonicalSkillDir))
408+
: records;
409+
}
410+
411+
function canonicalizeLoadedSkillRecord(
412+
record: LoadedSkillRecord,
413+
canonicalSkillDir: string,
414+
): LoadedSkillRecord {
415+
const originalBaseDir = path.resolve(record.skill.baseDir);
416+
const canonicalBaseDir = path.resolve(canonicalSkillDir);
417+
if (originalBaseDir === canonicalBaseDir) {
418+
return record;
419+
}
420+
const filePath = path.join(
421+
canonicalBaseDir,
422+
path.relative(originalBaseDir, record.skill.filePath),
423+
);
424+
return {
425+
...record,
426+
syncSourceDir: canonicalBaseDir,
427+
syncDirName: path.basename(originalBaseDir),
428+
skill: {
429+
...record.skill,
430+
filePath,
431+
baseDir: canonicalBaseDir,
432+
sourceInfo: record.skill.sourceInfo
433+
? {
434+
...record.skill.sourceInfo,
435+
path: filePath,
436+
baseDir: canonicalBaseDir,
437+
}
438+
: record.skill.sourceInfo,
439+
},
440+
};
401441
}
402442

403443
function isPathInsideAnyRoot(rootRealPaths: readonly string[], candidateRealPath: string): boolean {
@@ -437,6 +477,10 @@ function resolveSkillRootCandidatePath(params: {
437477
});
438478
}
439479

480+
function canonicalSkillDirForSource(source: string, skillDirRealPath: string): string | undefined {
481+
return shouldEnforceConfiguredSkillRootContainment(source) ? undefined : skillDirRealPath;
482+
}
483+
440484
function resolveSkillFilePath(params: {
441485
source: string;
442486
skillDir: string;
@@ -624,6 +668,7 @@ function loadSkillEntries(
624668
skillDir: baseDir,
625669
source: params.source,
626670
maxSkillFileBytes: limits.maxSkillFileBytes,
671+
canonicalSkillDir: canonicalSkillDirForSource(params.source, baseDirRealPath),
627672
});
628673
}
629674

@@ -658,7 +703,12 @@ function loadSkillEntries(
658703
}
659704

660705
const loadedSkills: LoadedSkillRecord[] = [];
661-
const loadCandidateSkill = ({ skillDir, name, skillMdRealPath }: CandidateSkillDir) => {
706+
const loadCandidateSkill = ({
707+
skillDir,
708+
skillDirRealPath,
709+
name,
710+
skillMdRealPath,
711+
}: CandidateSkillDir) => {
662712
try {
663713
const size = fs.statSync(skillMdRealPath).size;
664714
if (size > limits.maxSkillFileBytes) {
@@ -679,6 +729,7 @@ function loadSkillEntries(
679729
skillDir,
680730
source: params.source,
681731
maxSkillFileBytes: limits.maxSkillFileBytes,
732+
canonicalSkillDir: canonicalSkillDirForSource(params.source, skillDirRealPath),
682733
}),
683734
);
684735
};
@@ -707,7 +758,7 @@ function loadSkillEntries(
707758
candidatePath: skillMd,
708759
});
709760
if (skillMdRealPath) {
710-
loadCandidateSkill({ skillDir, name, skillMdRealPath });
761+
loadCandidateSkill({ skillDir, skillDirRealPath, name, skillMdRealPath });
711762
}
712763
} else {
713764
// No SKILL.md here — check one level deeper for grouped skill directories.
@@ -764,6 +815,7 @@ function loadSkillEntries(
764815
if (nestedDirRealPath && nestedSkillMdRealPath) {
765816
loadCandidateSkill({
766817
skillDir: nestedDir,
818+
skillDirRealPath: nestedDirRealPath,
767819
name: `${name}/${nestedName}`,
768820
skillMdRealPath: nestedSkillMdRealPath,
769821
});
@@ -884,6 +936,8 @@ function loadSkillEntries(
884936
frontmatter,
885937
metadata: resolveOpenClawMetadata(frontmatter),
886938
invocation,
939+
...(record.syncSourceDir !== undefined ? { syncSourceDir: record.syncSourceDir } : {}),
940+
...(record.syncDirName !== undefined ? { syncDirName: record.syncDirName } : {}),
887941
exposure: {
888942
includeInRuntimeRegistry: true,
889943
// Freshly loaded entries preserve the documented disable-model-invocation
@@ -1169,7 +1223,9 @@ function resolveSyncedSkillDestinationPath(params: {
11691223
entry: SkillEntry;
11701224
usedDirNames: Set<string>;
11711225
}): string | null {
1172-
const sourceDirName = path.basename(params.entry.skill.baseDir).trim();
1226+
const sourceDirName = (
1227+
params.entry.syncDirName ?? path.basename(params.entry.skill.baseDir)
1228+
).trim();
11731229
if (!sourceDirName || sourceDirName === "." || sourceDirName === "..") {
11741230
return null;
11751231
}
@@ -1233,7 +1289,7 @@ export async function syncSkillsToWorkspace(params: {
12331289
continue;
12341290
}
12351291
try {
1236-
await fsp.cp(entry.skill.baseDir, dest, {
1292+
await fsp.cp(entry.syncSourceDir ?? entry.skill.baseDir, dest, {
12371293
recursive: true,
12381294
force: true,
12391295
filter: (src) => {

0 commit comments

Comments
 (0)