diff --git a/docs/clawhub/cli.md b/docs/clawhub/cli.md index 6783c9f41a7d..7f2bcf359531 100644 --- a/docs/clawhub/cli.md +++ b/docs/clawhub/cli.md @@ -25,7 +25,7 @@ OpenClaw agent or Gateway. openclaw skills search "calendar" openclaw skills install @owner/ openclaw skills update @owner/ -openclaw skills verify +openclaw skills verify @owner/ openclaw plugins search "calendar" openclaw plugins install clawhub: diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 96e4be176a55..bdbb39932b93 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -38,11 +38,11 @@ openclaw skills update @owner/ --global openclaw skills update --all openclaw skills update --all --agent openclaw skills update --all --global -openclaw skills verify -openclaw skills verify --version -openclaw skills verify --tag -openclaw skills verify --card -openclaw skills verify --global +openclaw skills verify @owner/ +openclaw skills verify @owner/ --version +openclaw skills verify @owner/ --tag +openclaw skills verify @owner/ --card +openclaw skills verify @owner/ --global openclaw skills list openclaw skills list --eligible openclaw skills list --json @@ -105,8 +105,11 @@ Notes: target the shared managed skills directory instead of the workspace. - `update --all` updates tracked ClawHub installs in the selected workspace, or in the shared managed skills directory when combined with `--global`. -- `verify ` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by - default. There is no `--json` flag because JSON is already the default. +- `verify @owner/` prints ClawHub's `clawhub.skill.verify.v1` JSON + envelope by default. There is no `--json` flag because JSON is already the + default. Bare slugs remain accepted for compatibility when the skill is + already installed or unambiguous, but owner-qualified refs avoid publisher + ambiguity. - When ClawHub returns server-resolved source provenance, verify JSON also includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or self-declared source URLs stay only in the raw provenance envelope and are not diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 79e067102aff..d17759225ed2 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -152,8 +152,8 @@ publish and sync. | Update all workspace skills | `openclaw skills update --all` | | Update a shared managed skill | `openclaw skills update @owner/ --global` | | Update all shared managed skills | `openclaw skills update --all --global` | -| Verify a skill's trust envelope | `openclaw skills verify ` | -| Print the generated Skill Card | `openclaw skills verify --card` | +| Verify a skill's trust envelope | `openclaw skills verify @owner/` | +| Print the generated Skill Card | `openclaw skills verify @owner/ --card` | | Publish / sync via ClawHub CLI | `clawhub sync --all` | @@ -171,9 +171,11 @@ publish and sync. - `openclaw skills verify ` asks ClawHub for the skill's + `openclaw skills verify @owner/` asks ClawHub for the skill's `clawhub.skill.verify.v1` trust envelope. Installed ClawHub skills verify against the version and registry recorded in `.clawhub/origin.json`. + Bare slugs remain accepted for existing installed or unambiguous skills, but + owner-qualified refs avoid publisher ambiguity. ClawHub skill pages expose the latest security scan state before install, with detail pages for VirusTotal, ClawScan, and static analysis. The diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 20c6ebd74fd7..4f44d6ff2688 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -449,6 +449,24 @@ describe("skills cli commands", () => { expect(help).not.toContain("openclaw skills install weather"); }); + it("documents owner-qualified ClawHub verify refs in command help", () => { + const skillsCommand = createProgram().commands.find((command) => command.name() === "skills"); + const verifyCommand = skillsCommand?.commands.find((command) => command.name() === "verify"); + const output: string[] = []; + + verifyCommand?.configureOutput({ + writeOut: (value) => output.push(value), + writeErr: (value) => output.push(value), + }); + verifyCommand?.outputHelp(); + const help = output.join(""); + + expect(help).toContain(""); + expect(help).toContain("@owner/slug"); + expect(help).toContain("openclaw skills verify @owner/weather"); + expect(help).not.toContain("openclaw skills verify weather"); + }); + it("installs a skill from a git source into the active workspace", async () => { installSkillFromSourceMock.mockResolvedValue({ ok: true, @@ -935,6 +953,44 @@ describe("skills cli commands", () => { }); }); + it("passes owner-qualified verify refs and selectors through the resolver", async () => { + resolveClawHubSkillVerificationTargetMock.mockResolvedValueOnce({ + ok: true, + slug: "weather", + ownerHandle: "demo-owner", + baseUrl: "https://private.example.com/clawhub", + version: undefined, + tag: "latest", + resolution: { + source: "registry", + selector: "tag", + registry: "https://private.example.com/clawhub", + skillDir: undefined, + installedVersion: undefined, + }, + }); + + await runCommand(["skills", "verify", "@demo-owner/weather", "--tag", "latest", "--card"]); + + expect(resolveClawHubSkillVerificationTargetMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + slug: "@demo-owner/weather", + version: undefined, + tag: "latest", + }); + expect(fetchClawHubSkillVerificationMock).toHaveBeenCalledWith({ + slug: "weather", + ownerHandle: "demo-owner", + version: undefined, + tag: "latest", + baseUrl: "https://private.example.com/clawhub", + }); + expect(fetchClawHubSkillCardMock).toHaveBeenCalledWith({ + url: "https://private.example.com/clawhub/api/v1/skills/agentreceipt/card?version=1.2.3", + baseUrl: "https://private.example.com/clawhub", + }); + }); + it("passes explicit verify selectors and shared workspace options to the resolver", async () => { await runCommand(["skills", "verify", "agentreceipt", "--version", "2.0.0", "--global"]); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 79859ea8dfb6..5a151bc454e6 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -415,7 +415,7 @@ export function registerSkillsCli(program: Command) { skills .command("update") .description("Update ClawHub-installed skills in the active or shared managed directory") - .argument("[slug]", "Single skill slug") + .argument("[skill-ref]", "Single ClawHub skill ref (@owner/slug)") .option("--all", "Update all tracked ClawHub skills", false) .option( "--force-install", @@ -487,7 +487,7 @@ export function registerSkillsCli(program: Command) { skills .command("verify") .description("Verify a ClawHub skill with ClawHub") - .argument("", "ClawHub skill slug") + .argument("", "ClawHub skill ref (@owner/slug)") .option("--version ", "Verify a specific version") .option("--tag ", "Verify a dist tag") .option("--card", "Print the generated Skill Card Markdown", false) @@ -497,6 +497,7 @@ export function registerSkillsCli(program: Command) { false, ) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .addHelpText("after", "\nExamples:\n openclaw skills verify @owner/weather\n") .action( async ( slug: string, diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index 64ba2845d748..f94525e6897c 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -194,6 +194,80 @@ describe("skills verify CLI", () => { expect(mocks.runtimeErrors).toStrictEqual([]); }); + it("verifies owner-qualified registry refs with explicit versions", async () => { + mocks.fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + skill: { slug: "weather" }, + publisher: { handle: "demo-owner" }, + version: { version: "2.0.0" }, + card: { available: true }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: null, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + + await runCommand(["skills", "verify", "@demo-owner/weather", "--version", "2.0.0"]); + + expect(mocks.fetchClawHubSkillVerificationMock).toHaveBeenCalledWith({ + slug: "weather", + ownerHandle: "demo-owner", + version: "2.0.0", + tag: undefined, + baseUrl: "https://clawhub.ai", + }); + const payload = JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}") as { + openclaw?: { resolution?: { source?: string; selector?: string } }; + }; + expect(payload.openclaw?.resolution).toMatchObject({ + source: "registry", + selector: "version", + }); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); + expect(mocks.runtimeErrors).toStrictEqual([]); + }); + + it("prints cards for owner-qualified tag verification", async () => { + mocks.fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + skill: { slug: "weather" }, + publisher: { handle: "demo-owner" }, + version: { version: "2.0.0" }, + card: { + available: true, + url: "https://cards.example.test/generated/weather.md", + }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: null, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + mocks.fetchClawHubSkillCardMock.mockResolvedValueOnce("# Weather\n"); + + await runCommand(["skills", "verify", "@demo-owner/weather", "--tag", "latest", "--card"]); + + expect(mocks.fetchClawHubSkillVerificationMock).toHaveBeenCalledWith({ + slug: "weather", + ownerHandle: "demo-owner", + version: undefined, + tag: "latest", + baseUrl: "https://clawhub.ai", + }); + expect(mocks.fetchClawHubSkillCardMock).toHaveBeenCalledWith({ + url: "https://cards.example.test/generated/weather.md", + baseUrl: "https://clawhub.ai", + }); + expect(mocks.runtimeStdout.at(-1)).toBe("# Weather"); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); + expect(mocks.runtimeErrors).toStrictEqual([]); + }); + it("surfaces only server-verified source provenance in verify JSON", async () => { const sourceUrl = "https://github.com/openclaw/skills/tree/main/agentreceipt"; const verifiedSourceUrl = diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 07323b4834db..ae029a404bde 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -1329,6 +1329,66 @@ describe("skills-clawhub", () => { } }); + it("accepts owner-qualified installed verification targets", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-verify-"); + try { + await writeClawHubOriginFixture({ + workspaceDir, + slug: "weather", + ownerHandle: "demo-owner", + registry: "https://private.example.com/clawhub", + installedVersion: "2.0.0", + }); + + await expect( + resolveClawHubSkillVerificationTarget({ + workspaceDir, + slug: "@demo-owner/weather", + }), + ).resolves.toMatchObject({ + ok: true, + slug: "weather", + ownerHandle: "demo-owner", + baseUrl: "https://private.example.com/clawhub", + version: "2.0.0", + tag: undefined, + resolution: { + source: "installed", + selector: "installed-version", + registry: "https://private.example.com/clawhub", + installedVersion: "2.0.0", + }, + }); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("rejects owner-qualified installed verification when the owner differs", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-verify-"); + try { + await writeClawHubOriginFixture({ + workspaceDir, + slug: "weather", + ownerHandle: "demo-owner", + }); + + const result = await resolveClawHubSkillVerificationTarget({ + workspaceDir, + slug: "@other-owner/weather", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected owner mismatch failure"); + } + expect(result.error).toContain("is tracked as @demo-owner/weather"); + expect(result.error).toContain("not @other-owner/weather"); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("keeps the installed registry when an explicit version overrides the installed version", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-verify-")); try { @@ -1634,6 +1694,79 @@ describe("skills-clawhub", () => { } }); + it("uses owner-qualified registry verification targets", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-verify-"); + resolveClawHubBaseUrlMock.mockReturnValueOnce("https://configured.example.com/clawhub"); + try { + await expect( + resolveClawHubSkillVerificationTarget({ + workspaceDir, + slug: "@demo-owner/weather", + baseUrl: "https://configured.example.com/clawhub/", + }), + ).resolves.toEqual({ + ok: true, + slug: "weather", + ownerHandle: "demo-owner", + baseUrl: "https://configured.example.com/clawhub", + version: undefined, + tag: undefined, + resolution: { + source: "registry", + selector: "latest", + registry: "https://configured.example.com/clawhub", + skillDir: undefined, + installedVersion: undefined, + }, + }); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("keeps owner-qualified registry selectors for explicit versions and tags", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-verify-"); + try { + await expect( + resolveClawHubSkillVerificationTarget({ + workspaceDir, + slug: "@demo-owner/weather", + version: "2.0.0", + }), + ).resolves.toMatchObject({ + ok: true, + slug: "weather", + ownerHandle: "demo-owner", + version: "2.0.0", + tag: undefined, + resolution: { + source: "registry", + selector: "version", + }, + }); + + await expect( + resolveClawHubSkillVerificationTarget({ + workspaceDir, + slug: "@demo-owner/weather", + tag: "beta", + }), + ).resolves.toMatchObject({ + ok: true, + slug: "weather", + ownerHandle: "demo-owner", + version: undefined, + tag: "beta", + resolution: { + source: "registry", + selector: "tag", + }, + }); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("fails clearly when installed origin metadata is malformed", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-verify-")); try { diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 8075b80cc808..cb8eadb0b29c 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -956,7 +956,8 @@ export async function resolveClawHubSkillVerificationTarget(params: { return { ok: false, error: "Use either --version or --tag." }; } - const trackedSlug = normalizeTrackedSkillSlug(params.slug); + const requestedRef = parseRequestedClawHubSkillRef(params.slug); + const trackedSlug = requestedRef.slug; const skillDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug); const originRead = await readClawHubSkillOriginStrict(skillDir); if (originRead.kind === "malformed") { @@ -997,6 +998,13 @@ export async function resolveClawHubSkillVerificationTarget(params: { error: `Skill "${trackedSlug}" ClawHub origin metadata does not match the workspace ClawHub lockfile. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`, }; } + if (requestedRef.ownerHandle && lockedOwnerHandle !== requestedRef.ownerHandle) { + const trackedRef = lockedOwnerHandle ? `@${lockedOwnerHandle}/${trackedSlug}` : trackedSlug; + return { + ok: false, + error: `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`, + }; + } const selector: ClawHubSkillVerificationSelector = version ? "version" : tag @@ -1033,12 +1041,12 @@ export async function resolveClawHubSkillVerificationTarget(params: { }; } - const slug = validateRequestedSkillSlug(params.slug); const registry = resolveClawHubBaseUrl(params.baseUrl); const selector: ClawHubSkillVerificationSelector = version ? "version" : tag ? "tag" : "latest"; return { ok: true, - slug, + slug: requestedRef.slug, + ...(requestedRef.ownerHandle ? { ownerHandle: requestedRef.ownerHandle } : {}), baseUrl: registry, version, tag,