mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
aba94bbe0b
* fix(skills): keep ClawHub publisher identity from search through install ClawHub search returns one entry per publisher, so several results can share a slug. Every client collapsed the selection to that bare slug before calling skills.detail and skills.install, and ClawHub answered 409 AMBIGUOUS_SKILL_SLUG with no in-product way forward. searchClawHubSkills now records the publisher-qualified reference once, on the result that carries it, and the Gateway protocol documents it. skills.detail parses the same reference grammar skills.install already accepted, so review and install cannot resolve to different publishers. Control UI carries that one reference through row actions, detail, busy state, and acknowledgement retries, and shows it so otherwise identical rows are distinguishable. Fixes #117633 * fix(apps): send the ClawHub publisher reference from native skill browsers macOS, iOS, and Android read the qualified reference from search results and use it for skills.detail, install, busy state, installed matching, and list identity, so two publishers sharing a slug stay distinct instead of collapsing into one ambiguous request. * fix(skills): refuse external-source skill detail instead of reading a same-slug skill ClawHub has no source-qualified read endpoint, so a skills-sh reference parsed down to its bare slug would have returned a registry skill's card while install resolved the external artifact. Review and install could name different skills. skills.detail now fails closed on any reference that carries a source, and the macOS and AgentPro rows show the publisher reference next to the summary instead of only when a summary is missing, so same-slug rows stay distinguishable. * chore(apps): refresh native i18n source baseline for the skill row references * refactor(skills): drop the unread search-result ownerHandle field installRef is the one reference clients send back, and no client reads the publisher handle separately, so the protocol and Control UI carry one field instead of two. * fix(skills): name the next step when external skill detail is refused Clients that gate install behind a successful review would otherwise see only a refusal, so the error names the direct install path and the CLI equivalent. * fix(macos): use a doc comment on the ClawHub row subtitle swift-format's docComments rule requires doc comments on declarations; the subtitle property carried a regular comment and failed macos-swift. * fix(skills): carry ClawHub trust state to clients that can install Forwarding installRef let clients install the exact publisher the operator picked, including external skills-sh sources. It did not forward the trust state that says ClawHub never scanned that source, so iOS AgentPro — the one surface that installs in a single tap with no review step — could install an unscanned artifact with nothing on screen saying so. The CLI already labels these (docs/clawhub/cli.md, docs/cli/skills.md); native clients could not, because trustState was never on the wire. trustState becomes an optional field on SkillsSearchResultSchema. It is purely additive: older clients ignore an unknown key and the field is absent for registry results, so downgraded readers are unaffected and no protocol version moves. Every client that renders a search row now shows "Not scanned by ClawHub", matching the CLI wording exactly: iOS AgentPro in the row above the install button, macOS and Android beside the review action, and Control UI on the row that explains why review is refused for these sources. Covered by a wire assertion that the state reaches clients for an external source and stays absent for registry rows, plus decode-and-label tests on the shared Swift kit and the Android parser, and a Control UI render assertion. * fix(ui): size the ClawHub detail dialog to a refusal message Refusing detail for an external source made an error-only dialog reachable. The shared preview panel reserves a tall reader height for skill documents, so a two-line refusal rendered in a mostly empty dialog and read as broken rather than deliberate. Found by inspecting the review captures. * revert(ui,apps): drop the ClawHub trust label layer Maintainer product decision: skills.sh runs its own scanners, so OpenClaw does not add a second alert layer in the apps. Removes the label from Control UI, iOS, macOS and Android, and drops the trustState wire field that nothing would render. The CLI keeps its existing label; changing that is a separate call. Publisher identity, the fail-closed detail refusal, and the message-only dialog are unchanged. Splits the oversized skills view test file to satisfy max-lines without a suppression. * test(ui): fix ClawHub skill fixture checks * chore(plugin-sdk): refresh API baseline --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
// Verifies ClawHub skill icons, telemetry, metadata, verification, and cards.
|
|
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { reportClawHubPluginInstallTelemetry } from "./clawhub-packages.js";
|
|
import {
|
|
fetchClawHubSkillCard,
|
|
fetchClawHubSkillDetail,
|
|
fetchClawHubSkillInstallResolution,
|
|
fetchClawHubSkillSecurityVerdicts,
|
|
fetchClawHubSkillVerification,
|
|
reportClawHubSkillInstallTelemetry,
|
|
searchClawHubSkills,
|
|
} from "./clawhub-skills.js";
|
|
|
|
function malformedUtf8(prefix: string, suffix: string): ArrayBuffer {
|
|
const prefixBytes = new TextEncoder().encode(prefix);
|
|
const suffixBytes = new TextEncoder().encode(suffix);
|
|
const buffer = new ArrayBuffer(prefixBytes.byteLength + 1 + suffixBytes.byteLength);
|
|
const bytes = new Uint8Array(buffer);
|
|
bytes.set(prefixBytes);
|
|
bytes[prefixBytes.byteLength] = 0xff;
|
|
bytes.set(suffixBytes, prefixBytes.byteLength + 1);
|
|
return buffer;
|
|
}
|
|
|
|
describe("clawhub skills", () => {
|
|
afterEach(() => {
|
|
delete process.env.CLAWHUB_TOKEN;
|
|
delete process.env.CLAWHUB_DISABLE_TELEMETRY;
|
|
delete process.env.CLAWDHUB_DISABLE_TELEMETRY;
|
|
});
|
|
|
|
it("resolves hosted skill icons against the configured ClawHub origin", async () => {
|
|
await expect(
|
|
searchClawHubSkills({
|
|
query: "playwright",
|
|
baseUrl: "https://registry.example",
|
|
fetchImpl: async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
results: [
|
|
{
|
|
score: 1,
|
|
slug: "playwright-interactive",
|
|
displayName: "Playwright Interactive",
|
|
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
},
|
|
],
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
),
|
|
}),
|
|
).resolves.toMatchObject([
|
|
{
|
|
icon: `https://registry.example/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects skill icons outside the configured hosted-icon route", async () => {
|
|
const fetchImpl: typeof fetch = async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
results: [
|
|
{
|
|
score: 1,
|
|
slug: "external",
|
|
displayName: "External",
|
|
icon: `https://tracker.example/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
},
|
|
{
|
|
score: 1,
|
|
slug: "wrong-path",
|
|
displayName: "Wrong Path",
|
|
icon: "https://registry.example/icon.png",
|
|
},
|
|
],
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
|
|
await expect(
|
|
searchClawHubSkills({ query: "icons", baseUrl: "https://registry.example", fetchImpl }),
|
|
).resolves.toMatchObject([{ icon: undefined }, { icon: undefined }]);
|
|
});
|
|
|
|
it("gives every search result the reference detail and install must send back", async () => {
|
|
const fetchImpl: typeof fetch = async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
results: [
|
|
{ score: 2, slug: "email", ownerHandle: "alice", displayName: "Email" },
|
|
{ score: 1, slug: "email", ownerHandle: "bob", displayName: "Email" },
|
|
{ score: 1, slug: "orphan", displayName: "Orphan" },
|
|
{
|
|
score: 1,
|
|
slug: "weather",
|
|
installRef: "skills-sh:openclaw/skills/weather",
|
|
trustState: "not-scanned-by-clawhub",
|
|
displayName: "Weather",
|
|
},
|
|
],
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
|
|
await expect(
|
|
searchClawHubSkills({ query: "email", baseUrl: "https://registry.example", fetchImpl }).then(
|
|
(results) => results.map((entry) => entry.installRef),
|
|
),
|
|
).resolves.toEqual([
|
|
"@alice/email",
|
|
"@bob/email",
|
|
undefined,
|
|
"skills-sh:openclaw/skills/weather",
|
|
]);
|
|
});
|
|
|
|
it("preserves the legacy telemetry opt-out when the primary env is blank", async () => {
|
|
process.env.CLAWHUB_DISABLE_TELEMETRY = " ";
|
|
process.env.CLAWDHUB_DISABLE_TELEMETRY = "true";
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
|
|
|
await reportClawHubSkillInstallTelemetry({
|
|
token: "test-token",
|
|
slug: "calendar",
|
|
fetchImpl,
|
|
});
|
|
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends canonical plugin install telemetry", async () => {
|
|
let requestBody: unknown;
|
|
const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
if (typeof init?.body !== "string") {
|
|
throw new Error("Expected JSON request body");
|
|
}
|
|
requestBody = JSON.parse(init.body) as unknown;
|
|
return new Response(null, { status: 200 });
|
|
});
|
|
|
|
await reportClawHubPluginInstallTelemetry({
|
|
token: "test-token",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "2026.7.23",
|
|
fetchImpl,
|
|
});
|
|
|
|
expect(requestBody).toEqual({
|
|
event: "plugin_install",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "2026.7.23",
|
|
});
|
|
});
|
|
|
|
it("applies the install telemetry opt-out to plugin reports", async () => {
|
|
process.env.CLAWHUB_DISABLE_TELEMETRY = "true";
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
|
|
|
await reportClawHubPluginInstallTelemetry({
|
|
token: "test-token",
|
|
packageName: "@openclaw/voice-call",
|
|
fetchImpl,
|
|
});
|
|
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves skills-sh references in install telemetry", async () => {
|
|
let body: unknown;
|
|
|
|
await reportClawHubSkillInstallTelemetry({
|
|
token: "test-token",
|
|
slug: "weather",
|
|
version: "a".repeat(40),
|
|
requestedReference: "skills-sh:openclaw/skills/weather",
|
|
trustState: "not-scanned-by-clawhub",
|
|
fetchImpl: async (_input, init) => {
|
|
expect(typeof init?.body).toBe("string");
|
|
body = JSON.parse(init?.body as string);
|
|
return new Response(null, { status: 200 });
|
|
},
|
|
});
|
|
|
|
expect(body).toMatchObject({
|
|
event: "install",
|
|
slug: "weather",
|
|
version: "a".repeat(40),
|
|
reference: "skills-sh:openclaw/skills/weather",
|
|
trustState: "not-scanned-by-clawhub",
|
|
});
|
|
});
|
|
|
|
it("treats an empty primary telemetry setting as absent", async () => {
|
|
process.env.CLAWHUB_DISABLE_TELEMETRY = "";
|
|
process.env.CLAWDHUB_DISABLE_TELEMETRY = "true";
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
|
|
|
await reportClawHubSkillInstallTelemetry({
|
|
token: "test-token",
|
|
slug: "calendar",
|
|
fetchImpl,
|
|
});
|
|
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets a nonblank primary telemetry setting override the legacy opt-out", async () => {
|
|
process.env.CLAWHUB_DISABLE_TELEMETRY = "false";
|
|
process.env.CLAWDHUB_DISABLE_TELEMETRY = "true";
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
|
|
|
await reportClawHubSkillInstallTelemetry({
|
|
token: "test-token",
|
|
slug: "calendar",
|
|
fetchImpl,
|
|
});
|
|
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("sends owner-qualified skill detail lookups as slug plus ownerHandle", async () => {
|
|
let requestedUrl = "";
|
|
|
|
await expect(
|
|
fetchClawHubSkillDetail({
|
|
slug: "weather",
|
|
ownerHandle: "demo-owner",
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
skill: {
|
|
slug: "weather",
|
|
displayName: "Weather",
|
|
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
skill: {
|
|
slug: "weather",
|
|
icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
},
|
|
});
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/weather");
|
|
expect(url.searchParams.get("ownerHandle")).toBe("demo-owner");
|
|
});
|
|
|
|
it("sends owner-qualified skill install resolution lookups as slug plus ownerHandle", async () => {
|
|
let requestedUrl = "";
|
|
|
|
await expect(
|
|
fetchClawHubSkillInstallResolution({
|
|
slug: "weather",
|
|
ownerHandle: "demo-owner",
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
ok: true,
|
|
slug: "weather",
|
|
installKind: "archive",
|
|
archive: {
|
|
version: "1.0.0",
|
|
downloadUrl: "https://clawhub.ai/api/v1/download?slug=weather&version=1.0.0",
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, slug: "weather" });
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/weather/install");
|
|
expect(url.searchParams.get("ownerHandle")).toBe("demo-owner");
|
|
});
|
|
|
|
it("sends skills-sh references to the ClawHub install resolver", async () => {
|
|
let requestedUrl = "";
|
|
const reference = "skills-sh:openclaw/skills/weather";
|
|
|
|
await fetchClawHubSkillInstallResolution({
|
|
slug: "weather",
|
|
requestedReference: reference,
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
ok: true,
|
|
slug: "weather",
|
|
installKind: "github",
|
|
trust: { state: "not-scanned-by-clawhub" },
|
|
github: {
|
|
repo: "openclaw/skills",
|
|
path: "skills/weather",
|
|
commit: "a".repeat(40),
|
|
contentHash: "sha256:approved",
|
|
sourceUrl: "https://github.com/openclaw/skills",
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
},
|
|
});
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/weather/install");
|
|
expect(url.searchParams.get("reference")).toBe(reference);
|
|
});
|
|
|
|
it("fetches skill verification reports and lets version take precedence over tag", async () => {
|
|
let requestedUrl = "";
|
|
const envelope = {
|
|
schema: "clawhub.skill.verify.v1",
|
|
ok: true,
|
|
decision: "pass",
|
|
reasons: [],
|
|
skill: { slug: "agentreceipt", displayName: "Agent Receipt" },
|
|
publisher: { handle: "openclaw" },
|
|
version: { version: "1.2.3", tag: "stable" },
|
|
card: {
|
|
available: true,
|
|
url: "https://clawhub.ai/api/v1/skills/agentreceipt/card?version=1.2.3",
|
|
},
|
|
artifact: {
|
|
sourceFingerprint: "source-fp",
|
|
bundleFingerprints: ["generated-bundle-fp"],
|
|
},
|
|
provenance: null,
|
|
security: { status: "clean" },
|
|
signature: { status: "unsigned" },
|
|
};
|
|
|
|
await expect(
|
|
fetchClawHubSkillVerification({
|
|
slug: "agentreceipt",
|
|
version: "1.2.3",
|
|
tag: "stable",
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response(JSON.stringify(envelope), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
},
|
|
}),
|
|
).resolves.toEqual(envelope);
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/agentreceipt/verify");
|
|
expect(url.searchParams.get("version")).toBe("1.2.3");
|
|
expect(url.searchParams.has("tag")).toBe(false);
|
|
});
|
|
|
|
it("sends owner-qualified skill verification lookups without resolved auth when requested", async () => {
|
|
process.env.CLAWHUB_TOKEN = "test-auth-token";
|
|
let requestedUrl = "";
|
|
let requestedInit: RequestInit | undefined;
|
|
|
|
await expect(
|
|
fetchClawHubSkillVerification({
|
|
slug: "weather",
|
|
ownerHandle: "demo-owner",
|
|
version: "1.0.0",
|
|
skipAuth: true,
|
|
fetchImpl: async (input, init) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
requestedInit = init;
|
|
return new Response(
|
|
JSON.stringify({
|
|
schema: "clawhub.skill.verify.v1",
|
|
ok: true,
|
|
decision: "pass",
|
|
reasons: [],
|
|
skill: {},
|
|
publisher: {},
|
|
version: {},
|
|
card: {},
|
|
artifact: {},
|
|
provenance: {},
|
|
security: {},
|
|
signature: {},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ schema: "clawhub.skill.verify.v1" });
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/weather/verify");
|
|
expect(url.searchParams.get("ownerHandle")).toBe("demo-owner");
|
|
expect(url.searchParams.get("version")).toBe("1.0.0");
|
|
expect(new Headers(requestedInit?.headers).get("Authorization")).toBeNull();
|
|
});
|
|
|
|
it("posts bulk skill security verdict requests", async () => {
|
|
let requestedUrl = "";
|
|
let requestedInit: RequestInit | undefined;
|
|
const envelope = {
|
|
schema: "clawhub.skill.security-verdicts.v1",
|
|
items: [
|
|
{
|
|
ok: true,
|
|
decision: "pass",
|
|
reasons: [],
|
|
requestedSlug: "agentreceipt",
|
|
slug: "agentreceipt",
|
|
requestedVersion: "1.2.3",
|
|
version: "1.2.3",
|
|
security: { status: "clean", passed: true },
|
|
},
|
|
],
|
|
};
|
|
|
|
await expect(
|
|
fetchClawHubSkillSecurityVerdicts({
|
|
items: [{ slug: "agentreceipt", ownerHandle: "openclaw", version: "1.2.3" }],
|
|
fetchImpl: async (input, init) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
requestedInit = init;
|
|
return new Response(JSON.stringify(envelope), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
},
|
|
}),
|
|
).resolves.toEqual(envelope);
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/-/security-verdicts");
|
|
expect(requestedInit?.method).toBe("POST");
|
|
expect(requestedInit?.headers).toMatchObject({ "Content-Type": "application/json" });
|
|
expect(requestedInit?.body).toBe(
|
|
JSON.stringify({
|
|
items: [{ slug: "agentreceipt", ownerHandle: "openclaw", version: "1.2.3" }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("can post bulk skill security verdict requests without resolved auth", async () => {
|
|
process.env.CLAWHUB_TOKEN = "test-auth-token";
|
|
let requestedInit: RequestInit | undefined;
|
|
const envelope = {
|
|
schema: "clawhub.skill.security-verdicts.v1",
|
|
items: [],
|
|
};
|
|
|
|
await expect(
|
|
fetchClawHubSkillSecurityVerdicts({
|
|
items: [{ slug: "agentreceipt", version: "1.2.3" }],
|
|
skipAuth: true,
|
|
fetchImpl: async (_input, init) => {
|
|
requestedInit = init;
|
|
return new Response(JSON.stringify(envelope), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
},
|
|
}),
|
|
).resolves.toEqual(envelope);
|
|
|
|
expect(new Headers(requestedInit?.headers).get("Authorization")).toBeNull();
|
|
});
|
|
|
|
it("returns failed skill verification reports with missing card reasons", async () => {
|
|
const envelope = {
|
|
schema: "clawhub.skill.verify.v1",
|
|
ok: false,
|
|
decision: "fail",
|
|
reasons: ["card.missing"],
|
|
skill: { slug: "agentreceipt" },
|
|
publisher: null,
|
|
version: { version: "1.2.3" },
|
|
card: { available: false },
|
|
artifact: null,
|
|
provenance: null,
|
|
security: { status: "clean" },
|
|
signature: { status: "unsigned" },
|
|
};
|
|
|
|
await expect(
|
|
fetchClawHubSkillVerification({
|
|
slug: "agentreceipt",
|
|
fetchImpl: async () =>
|
|
new Response(JSON.stringify(envelope), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
}),
|
|
).resolves.toEqual(envelope);
|
|
});
|
|
|
|
it("fetches generated Skill Card markdown and applies tag queries", async () => {
|
|
let requestedUrl = "";
|
|
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
slug: "agentreceipt",
|
|
tag: "latest",
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response("# Agent Receipt\n\nVerified by ClawHub.\n", {
|
|
status: 200,
|
|
headers: { "content-type": "text/markdown; charset=utf-8" },
|
|
});
|
|
},
|
|
}),
|
|
).resolves.toBe("# Agent Receipt\n\nVerified by ClawHub.\n");
|
|
|
|
const url = new URL(requestedUrl);
|
|
expect(url.pathname).toBe("/api/v1/skills/agentreceipt/card");
|
|
expect(url.searchParams.get("tag")).toBe("latest");
|
|
expect(url.searchParams.has("version")).toBe(false);
|
|
});
|
|
|
|
it("clamps oversized ClawHub request timeouts before scheduling", async () => {
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
try {
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
slug: "agentreceipt",
|
|
timeoutMs: Number.MAX_SAFE_INTEGER,
|
|
fetchImpl: async () =>
|
|
new Response("# Agent Receipt\n", {
|
|
status: 200,
|
|
headers: { "content-type": "text/markdown; charset=utf-8" },
|
|
}),
|
|
}),
|
|
).resolves.toBe("# Agent Receipt\n");
|
|
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects malformed UTF-8 in generated Skill Card markdown", async () => {
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
slug: "agentreceipt",
|
|
fetchImpl: async () => new Response(malformedUtf8("# Agent ", "\n")),
|
|
}),
|
|
).rejects.toThrow(TypeError);
|
|
});
|
|
|
|
it("fetches generated Skill Card markdown from an exact verified card URL", async () => {
|
|
let requestedUrl = "";
|
|
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
url: "https://cards.example.test/generated/agentreceipt.md",
|
|
baseUrl: "https://clawhub.ai",
|
|
fetchImpl: async (input) => {
|
|
requestedUrl = input instanceof Request ? input.url : String(input);
|
|
return new Response("# Agent Receipt\n", {
|
|
status: 200,
|
|
headers: { "content-type": "text/markdown; charset=utf-8" },
|
|
});
|
|
},
|
|
}),
|
|
).resolves.toBe("# Agent Receipt\n");
|
|
|
|
expect(requestedUrl).toBe("https://cards.example.test/generated/agentreceipt.md");
|
|
});
|
|
|
|
it("wraps non-200 skill card responses", async () => {
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
slug: "agentreceipt",
|
|
fetchImpl: async () => new Response("card missing", { status: 404 }),
|
|
}),
|
|
).rejects.toThrow("ClawHub /api/v1/skills/agentreceipt/card failed (404): card missing");
|
|
});
|
|
|
|
it("rejects oversized generated Skill Card markdown", async () => {
|
|
await expect(
|
|
fetchClawHubSkillCard({
|
|
slug: "agentreceipt",
|
|
fetchImpl: async () => new Response("x".repeat(256 * 1024 + 1)),
|
|
}),
|
|
).rejects.toThrow(
|
|
"ClawHub skill card for agentreceipt exceeded 262144 bytes (262145 bytes received)",
|
|
);
|
|
});
|
|
|
|
it("wraps non-200 skill verification responses", async () => {
|
|
await expect(
|
|
fetchClawHubSkillVerification({
|
|
slug: "agentreceipt",
|
|
fetchImpl: async () => new Response("not found", { status: 404 }),
|
|
}),
|
|
).rejects.toThrow("ClawHub /api/v1/skills/agentreceipt/verify failed (404): not found");
|
|
});
|
|
});
|