fix(skills): globally-installed ClawHub skills falsely show as not tracked [AI-assisted] (#106479)

* fix(skills): link globally-installed ClawHub skills via managed lockfile

buildWorkspaceSkillStatus read the ClawHub lockfile only from the workspace
dir, so globally-installed skills (source "openclaw-managed", tracked in the
managed lockfile at <dirname(managedSkillsDir)>/.clawhub/lock.json) were
falsely reported as "not tracked by the workspace ClawHub lockfile". Resolve
openclaw-managed skills against the managed lockfile and managed parent dir
so the link status and expected-install-dir check match. Workspace-local
skills are unchanged.

Closes #105530

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(skills): make ClawHub lockfile diagnostics scope-aware

resolveClawHubSkillStatusLinkSync hardcoded "workspace ClawHub lockfile" in
its invalid-path diagnostics, so a globally-managed skill (checked against
the managed lockfile) still got a misleading "workspace" troubleshooting
message. Add an optional lockfileScope ("workspace" | "managed") and use it
in the diagnostics; buildSkillStatus passes "managed" for openclaw-managed
skills. Extends the managed missing-entry regression test to assert the
managed diagnostic.

Addresses ClawSweeper [P2] review finding on #106479.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(skills): relabel managed-lockfile diagnostics from status.ts

Compute the managed-lockfile diagnostic relabel in status.ts instead of
threading a scope parameter through clawhub.ts. clawhub.ts is an oversized
legacy module under the check:loc ratchet ('oversized legacy files may not
grow'), so the prior +8 net lines tripped checks-fast-loc-ratchet. Behavior
is unchanged: globally-managed skills still surface 'managed ClawHub
lockfile' diagnostics.

* fix(skills): strengthen managed ClawHub status linkage

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ml12580
2026-07-14 15:16:55 +08:00
committed by GitHub
parent 69a578034f
commit ad17f451d8
3 changed files with 128 additions and 10 deletions

View File

@@ -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<typeof buildWorkspaceSkillStatus>["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.

View File

@@ -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,
}),
),
};

View File

@@ -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,