diff --git a/src/skills/discovery/status.test.ts b/src/skills/discovery/status.test.ts index 15ea478f5d5b..e2d9f5247608 100644 --- a/src/skills/discovery/status.test.ts +++ b/src/skills/discovery/status.test.ts @@ -2,13 +2,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { readLocalSkillCardContentSync } from "../lifecycle/clawhub.js"; import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; import type { SkillEntry } from "../types.js"; import { buildWorkspaceSkillStatus } from "./status.js"; type SkillStatus = ReturnType["skills"][number]; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("buildWorkspaceSkillStatus", () => { it("surfaces valid ClawHub linkage and local Skill Card metadata", async () => { @@ -238,6 +240,107 @@ describe("buildWorkspaceSkillStatus", () => { } }); + it("links a discovered global ClawHub skill only through the managed lockfile", async () => { + const managedParentDir = tempDirs.make("openclaw-managed-"); + const workspaceDir = tempDirs.make("openclaw-skill-status-"); + const managedSkillsDir = path.join(managedParentDir, "skills"); + const skillDir = path.join(managedSkillsDir, "agentreceipt"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: agentreceipt\ndescription: Global skill\n---\n", + "utf8", + ); + await writeClawHubStatusFixture({ + workspaceDir: managedParentDir, + skillDir, + slug: "agentreceipt", + }); + // Same slug in the workspace must not cross-link the managed install. + await writeClawHubStatusFixture({ + workspaceDir, + skillDir: path.join(workspaceDir, "unused"), + slug: "agentreceipt", + installedVersion: "9.9.9", + }); + + const report = buildWorkspaceSkillStatus(workspaceDir, { managedSkillsDir }); + const skill = report.skills.find((entry) => entry.skillKey === "agentreceipt"); + + expect(skill).toMatchObject({ source: "openclaw-managed" }); + expect(skill?.clawhub).toMatchObject({ + status: "linked", + valid: true, + slug: "agentreceipt", + installedVersion: "1.2.3", + lockPath: path.join(managedParentDir, ".clawhub", "lock.json"), + }); + }); + + it("reports a globally installed skill as invalid when it is absent from the managed lockfile", async () => { + const managedParentDir = tempDirs.make("openclaw-managed-"); + const workspaceDir = tempDirs.make("openclaw-skill-status-"); + const managedSkillsDir = path.join(managedParentDir, "skills"); + const skillDir = path.join(managedSkillsDir, "agentreceipt"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: agentreceipt\ndescription: Global skill\n---\n", + "utf8", + ); + await writeClawHubStatusFixture({ + workspaceDir: managedParentDir, + skillDir, + slug: "agentreceipt", + writeLock: false, + }); + + const report = buildWorkspaceSkillStatus(workspaceDir, { managedSkillsDir }); + const skill = report.skills.find((entry) => entry.skillKey === "agentreceipt"); + + expect(skill?.clawhub).toMatchObject({ + status: "invalid", + valid: false, + reason: expect.stringContaining("not tracked by the managed ClawHub lockfile"), + }); + }); + + it.runIf(process.platform !== "win32")( + "links a discovered managed skill whose install directory is a symlink", + async () => { + const managedParentDir = tempDirs.make("openclaw-managed-"); + const externalSkillDir = tempDirs.make("openclaw-skill-target-"); + const workspaceDir = tempDirs.make("openclaw-skill-status-"); + const managedSkillsDir = path.join(managedParentDir, "skills"); + await fs.mkdir(managedSkillsDir, { recursive: true }); + await fs.writeFile( + path.join(externalSkillDir, "SKILL.md"), + "---\nname: linked-skill\ndescription: Symlinked global skill\n---\n", + "utf8", + ); + await fs.symlink(externalSkillDir, path.join(managedSkillsDir, "linked-skill")); + await writeClawHubStatusFixture({ + workspaceDir: managedParentDir, + skillDir: externalSkillDir, + slug: "linked-skill", + }); + + const report = buildWorkspaceSkillStatus(workspaceDir, { managedSkillsDir }); + const skill = report.skills.find((entry) => entry.skillKey === "linked-skill"); + const externalSkillRealDir = await fs.realpath(externalSkillDir); + + expect(skill).toMatchObject({ + source: "openclaw-managed", + baseDir: externalSkillRealDir, + }); + expect(skill?.clawhub).toMatchObject({ + status: "linked", + valid: true, + lockPath: path.join(managedParentDir, ".clawhub", "lock.json"), + }); + }, + ); + it("does not surface install options for OS-scoped skills on unsupported platforms", () => { if (process.platform === "win32") { // Keep this simple; win32 platform naming is already explicitly handled elsewhere. diff --git a/src/skills/discovery/status.ts b/src/skills/discovery/status.ts index 9fa423c3db5d..84ceea079edf 100644 --- a/src/skills/discovery/status.ts +++ b/src/skills/discovery/status.ts @@ -252,6 +252,8 @@ type BuildSkillStatusContext = { agentSkillFilter?: string[]; workspaceDir: string; clawhubLockRead: ClawHubSkillsLockfileStatusRead; + managedSkillsDir: string; + managedLockRead: ClawHubSkillsLockfileStatusRead; }; function buildSkillStatus( @@ -294,13 +296,18 @@ function buildSkillStatus( const availableToAgent = eligible && !blockedByAgentFilter; const userInvocable = indexed.userInvocable; + // Source ownership survives canonicalization of symlinked managed installs. + const isGlobalManagedSkill = !bundled && skillSource === "openclaw-managed"; const clawhub = workspaceDir && !bundled ? resolveClawHubSkillStatusLinkSync({ - workspaceDir, + workspaceDir: isGlobalManagedSkill + ? path.dirname(path.resolve(context.managedSkillsDir)) + : workspaceDir, skillDir: entry.skill.baseDir, skillKey, - lockRead: context.clawhubLockRead, + lockRead: isGlobalManagedSkill ? context.managedLockRead : context.clawhubLockRead, + lockfileScope: isGlobalManagedSkill ? "managed" : "workspace", }) : undefined; const skillCard = resolveLocalSkillCardStatusSync(entry.skill.baseDir); @@ -367,6 +374,12 @@ export function buildWorkspaceSkillStatus( const prefs = resolveSkillsInstallPreferences(opts?.config); const allowBundled = resolveBundledAllowlist(opts?.config); const clawhubLockRead = readClawHubSkillsLockfileStatusSync(workspaceDir); + // Global installs are tracked beside managedSkillsDir, never by fallback. + const managedParentDir = path.dirname(path.resolve(managedSkillsDir)); + const managedLockRead = + managedParentDir === path.resolve(workspaceDir) + ? clawhubLockRead + : readClawHubSkillsLockfileStatusSync(managedParentDir); const skillIndexEntries = buildSkillIndexEntries(skillEntries, { bundledNames: bundledContext.names, agentSkillFilter, @@ -385,6 +398,8 @@ export function buildWorkspaceSkillStatus( agentSkillFilter, workspaceDir, clawhubLockRead, + managedSkillsDir, + managedLockRead, }), ), }; diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 660717caea4d..ce922a7f805f 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -737,10 +737,11 @@ export function resolveClawHubSkillStatusLinkSync(params: { skillDir: string; skillKey: string; lockRead?: ClawHubSkillsLockfileStatusRead; + lockfileScope?: "workspace" | "managed"; }): ClawHubSkillStatusLink | undefined { const originRead = readClawHubSkillOriginStatusSync(params.skillDir); const lockRead = params.lockRead ?? readClawHubSkillsLockfileStatusSync(params.workspaceDir); - + const lockfileLabel = `${params.lockfileScope ?? "workspace"} ClawHub lockfile`; if (originRead.kind === "missing") { let trackedSlug: string; try { @@ -755,7 +756,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { return { status: "invalid", valid: false, - reason: `Skill "${trackedSlug}" is tracked by the workspace ClawHub lockfile but is missing local ClawHub origin metadata.`, + reason: `Skill "${trackedSlug}" is tracked by the ${lockfileLabel} but is missing local ClawHub origin metadata.`, slug: trackedSlug, installedVersion: locked.version, installedAt: locked.installedAt, @@ -763,7 +764,6 @@ export function resolveClawHubSkillStatusLinkSync(params: { lockPath: lockRead.kind === "found" ? lockRead.path : undefined, }; } - if (originRead.kind === "malformed") { return { status: "invalid", @@ -795,7 +795,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { return { status: "invalid", valid: false, - reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the workspace ClawHub lockfile.`, + reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`, registry: originRead.origin.registry, slug: trackedSlug, installedVersion: originRead.origin.installedVersion, @@ -807,7 +807,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { return { status: "invalid", valid: false, - reason: `Malformed workspace ClawHub lockfile at ${lockRead.path}: ${lockRead.error}`, + reason: `Malformed ${lockfileLabel} at ${lockRead.path}: ${lockRead.error}`, registry: originRead.origin.registry, slug: trackedSlug, installedVersion: originRead.origin.installedVersion, @@ -821,7 +821,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { return { status: "invalid", valid: false, - reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the workspace ClawHub lockfile.`, + reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`, registry: originRead.origin.registry, slug: trackedSlug, installedVersion: originRead.origin.installedVersion, @@ -872,7 +872,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { return { status: "invalid", valid: false, - reason: `Skill "${trackedSlug}" ClawHub origin metadata does not match the workspace ClawHub lockfile.`, + reason: `Skill "${trackedSlug}" ClawHub origin metadata does not match the ${lockfileLabel}.`, registry: lockedRegistry, slug: trackedSlug, installedVersion: originRead.origin.installedVersion,