From ad17f451d8cdc2eb796d182fda0607a9ce8b3be2 Mon Sep 17 00:00:00 2001 From: ml12580 Date: Tue, 14 Jul 2026 15:16:55 +0800 Subject: [PATCH] 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 /.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 * 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 * 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 Co-authored-by: Peter Steinberger --- src/skills/discovery/status.test.ts | 105 +++++++++++++++++++++++++++- src/skills/discovery/status.ts | 19 ++++- src/skills/lifecycle/clawhub.ts | 14 ++-- 3 files changed, 128 insertions(+), 10 deletions(-) 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,