mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 04:31:12 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user