fix(skills): accept owner-qualified verify refs (#95992)

Merged via squash.

Prepared head SHA: de9f1e566e
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
Reviewed-by: @Patrick-Erichsen
This commit is contained in:
Patrick Erichsen 2026-06-23 12:06:56 -07:00 committed by GitHub
parent 59713194fc
commit 6343e1483f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 293 additions and 16 deletions

View file

@ -25,7 +25,7 @@ OpenClaw agent or Gateway.
openclaw skills search "calendar"
openclaw skills install @owner/<slug>
openclaw skills update @owner/<slug>
openclaw skills verify <slug>
openclaw skills verify @owner/<slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>

View file

@ -38,11 +38,11 @@ openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
openclaw skills update --all --global
openclaw skills verify <slug>
openclaw skills verify <slug> --version <version>
openclaw skills verify <slug> --tag <tag>
openclaw skills verify <slug> --card
openclaw skills verify <slug> --global
openclaw skills verify @owner/<slug>
openclaw skills verify @owner/<slug> --version <version>
openclaw skills verify @owner/<slug> --tag <tag>
openclaw skills verify @owner/<slug> --card
openclaw skills verify @owner/<slug> --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 <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
default. There is no `--json` flag because JSON is already the default.
- `verify @owner/<slug>` 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

View file

@ -152,8 +152,8 @@ publish and sync.
| Update all workspace skills | `openclaw skills update --all` |
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
| Update all shared managed skills | `openclaw skills update --all --global` |
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
| Verify a skill's trust envelope | `openclaw skills verify @owner/<slug>` |
| Print the generated Skill Card | `openclaw skills verify @owner/<slug> --card` |
| Publish / sync via ClawHub CLI | `clawhub sync --all` |
<AccordionGroup>
@ -171,9 +171,11 @@ publish and sync.
</Accordion>
<Accordion title="Verification and security scanning">
`openclaw skills verify <slug>` asks ClawHub for the skill's
`openclaw skills verify @owner/<slug>` 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

View file

@ -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("<skill-ref>");
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"]);

View file

@ -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("<slug>", "ClawHub skill slug")
.argument("<skill-ref>", "ClawHub skill ref (@owner/slug)")
.option("--version <version>", "Verify a specific version")
.option("--tag <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 <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
.addHelpText("after", "\nExamples:\n openclaw skills verify @owner/weather\n")
.action(
async (
slug: string,

View file

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

View file

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

View file

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