Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16688,4 +16688,143 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(403);
expect(await response.text()).toBe(moderationMessage);
});

it("keeps Claw list and search filters unavailable while the gate is disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
try {
const ctx = makeCtx({});
const listResponse = await __handlers.listPackagesV1Handler(
ctx,
new Request("https://example.com/api/v1/packages?family=claw"),
);
const searchResponse = await __handlers.packagesGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/packages/search?q=triage&family=claw"),
);

expect(listResponse.status).toBe(400);
await expect(listResponse.text()).resolves.toBe("Invalid family query parameter");
expect(searchResponse.status).toBe(400);
await expect(searchResponse.text()).resolves.toBe("Invalid family query parameter");
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});

it("lists and returns safe Claw details while the gate is enabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const latestClawManifestSummary = {
schemaVersion: 1,
agent: { id: "triage-latest", name: "Triage Latest", description: "Latest release" },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 1 },
packages: { skillCount: 1, pluginCount: 0 },
mcpServerCount: 1,
cronJobCount: 1,
};
const exactClawManifestSummary = {
schemaVersion: 1,
agent: { id: "triage-v1", name: "Triage v1", description: "Exact release" },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 2 },
packages: { skillCount: 2, pluginCount: 1 },
mcpServerCount: 2,
cronJobCount: 2,
};
const packageItem = {
_id: "packages:triage",
name: "triage-claw",
displayName: "Triage Claw",
family: "claw",
runtimeId: null,
channel: "community",
isOfficial: false,
summary: "Triage agent",
icon: null,
ownerHandle: "owner",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
categories: [],
topics: [],
verificationTier: null,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
tags: {},
compatibility: null,
verification: null,
artifact: null,
clawManifestSummary: latestClawManifestSummary,
};
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("paginationOpts" in args) {
return { page: [packageItem], isDone: true, continueCursor: "" };
}
if ("name" in args) {
if ("version" in args) {
return {
package: packageItem,
version: {
_id: "packageReleases:triage-1",
packageId: "packages:triage",
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
files: [],
clawManifestSummary: exactClawManifestSummary,
extractedClawManifest: { secret: "must-not-project" },
extractedPackageJson: { privatePolicy: "must-not-project" },
},
};
}
return {
package: packageItem,
latestRelease: null,
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
};
}
if ("releaseIds" in args) return [];
return null;
});

try {
const ctx = makeCtx({ runQuery });
const listResponse = await __handlers.listPackagesV1Handler(
ctx,
new Request("https://example.com/api/v1/packages?family=claw"),
);
if (listResponse.status !== 200) throw new Error(await listResponse.text());
await expect(listResponse.json()).resolves.toMatchObject({
items: [{ name: "triage-claw", family: "claw" }],
});

const detailResponse = await __handlers.packagesGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/packages/triage-claw"),
);
if (detailResponse.status !== 200) throw new Error(await detailResponse.text());
await expect(detailResponse.json()).resolves.toMatchObject({
package: {
name: "triage-claw",
family: "claw",
clawManifestSummary: latestClawManifestSummary,
},
});

const versionResponse = await __handlers.packagesGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/packages/triage-claw/versions/1.0.0"),
);
if (versionResponse.status !== 200) throw new Error(await versionResponse.text());
const versionBody = await versionResponse.json();
expect(versionBody).toMatchObject({
package: { name: "triage-claw", family: "claw" },
version: { version: "1.0.0", clawManifestSummary: exactClawManifestSummary },
});
expect(JSON.stringify(versionBody)).not.toContain("must-not-project");
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
});
31 changes: 22 additions & 9 deletions convex/httpApiV1/packagesV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
}

const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
const PACKAGE_FAMILY_VALUES_WITH_CLAWS = [...PACKAGE_FAMILY_VALUES, "claw"] as const;

function publicPackageFamilyValues() {
return experimentalClawsEnabled() ? PACKAGE_FAMILY_VALUES_WITH_CLAWS : PACKAGE_FAMILY_VALUES;
}
const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const;
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
const PACKAGE_LIST_SORT_VALUES = [
Expand Down Expand Up @@ -463,7 +468,7 @@ function parsePackageOfficialMigrationPhase(
}

type PackageListQueryArgs = {
family?: "skill" | "code-plugin" | "bundle-plugin";
family?: "skill" | "code-plugin" | "bundle-plugin" | "claw";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
Expand Down Expand Up @@ -522,6 +527,7 @@ type ReleaseLike = {
}>;
compatibility?: Doc<"packageReleases">["compatibility"];
pluginManifestSummary?: Doc<"packageReleases">["pluginManifestSummary"];
clawManifestSummary?: Doc<"packageReleases">["clawManifestSummary"];
verification?: Doc<"packageReleases">["verification"];
extractedPackageJson?: Doc<"packageReleases">["extractedPackageJson"];
sha256hash?: string;
Expand Down Expand Up @@ -829,7 +835,7 @@ async function resolvePackageTags(
type CatalogListItem = {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "skill" | "code-plugin" | "bundle-plugin" | "claw";
runtimeId?: string | null;
channel: "official" | "community" | "private";
isOfficial: boolean;
Expand Down Expand Up @@ -1162,7 +1168,7 @@ async function searchPackageCatalog(
args: {
query: string;
limit: number;
family?: "skill" | "code-plugin" | "bundle-plugin";
family?: "skill" | "code-plugin" | "bundle-plugin" | "claw";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
Expand Down Expand Up @@ -1553,7 +1559,7 @@ async function listPackages(
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 25, 100));
const rawCursor = url.searchParams.get("cursor");
const familyParam = parseEnumQueryParam(url.searchParams, "family", PACKAGE_FAMILY_VALUES);
const familyParam = parseEnumQueryParam(url.searchParams, "family", publicPackageFamilyValues());
if (!familyParam.ok) return text(familyParam.message, 400, rate.headers);
const channelParam = parseEnumQueryParam(url.searchParams, "channel", PACKAGE_CHANNEL_VALUES);
if (!channelParam.ok) return text(channelParam.message, 400, rate.headers);
Expand Down Expand Up @@ -1589,14 +1595,19 @@ async function listPackages(
: options?.defaultSort;
const isLegacyInstallSortRequest = sortParam.value === "installs";
const effectiveSort = normalizePublicPackageSort(sortParam.value ?? pluginDefaultSort);
if (category && (effectiveFamily === "skill" || (!effectiveFamily && includeSkills))) {
if (
category &&
(effectiveFamily === "skill" ||
effectiveFamily === "claw" ||
(!effectiveFamily && includeSkills))
) {
return text(
"Plugin category is only supported for plugin package endpoints",
400,
rate.headers,
);
}
if (effectiveSort === "trending" && includeSkills) {
if (effectiveSort === "trending" && (includeSkills || effectiveFamily === "claw")) {
return text(
"Trending sort is only supported for plugin package endpoints; use /api/v1/skills?sort=trending for skills.",
400,
Expand Down Expand Up @@ -3451,7 +3462,7 @@ async function searchPackages(
const queryText = url.searchParams.get("q")?.trim() ?? "";
if (!queryText) return text("Missing q query parameter", 400, rate.headers);
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
const familyParam = parseEnumQueryParam(url.searchParams, "family", PACKAGE_FAMILY_VALUES);
const familyParam = parseEnumQueryParam(url.searchParams, "family", publicPackageFamilyValues());
if (!familyParam.ok) return text(familyParam.message, 400, rate.headers);
const channelParam = parseEnumQueryParam(url.searchParams, "channel", PACKAGE_CHANNEL_VALUES);
if (!channelParam.ok) return text(channelParam.message, 400, rate.headers);
Expand All @@ -3474,7 +3485,7 @@ async function searchPackages(
}
const family = familyParam.value;
const includeSkills = options?.includeSkills ?? family === undefined;
if (category && (family === "skill" || (!family && includeSkills))) {
if (category && (family === "skill" || family === "claw" || (!family && includeSkills))) {
return text(
"Plugin category is only supported for plugin package endpoints",
400,
Expand Down Expand Up @@ -4040,6 +4051,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
})),
compatibility: result.version.compatibility ?? null,
pluginManifestSummary: result.version.pluginManifestSummary ?? null,
clawManifestSummary: result.version.clawManifestSummary ?? null,
verification,
artifact: toReleaseArtifact(result.version, result.package.name),
sha256hash: result.version.sha256hash ?? null,
Expand Down Expand Up @@ -4351,7 +4363,7 @@ type PublicPackageDocLike = {
_id: Id<"packages">;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "skill" | "code-plugin" | "bundle-plugin" | "claw";
tags: Record<string, Id<"packageReleases">>;
latestReleaseId?: Id<"packageReleases">;
channel: "official" | "community" | "private";
Expand All @@ -4374,6 +4386,7 @@ type PublicPackageDocLike = {
npmUnpackedSize?: number;
npmFileCount?: number;
};
clawManifestSummary?: Doc<"packageReleases">["clawManifestSummary"];
stats?: { downloads: number; installs: number; stars: number; versions: number };
createdAt: number;
updatedAt: number;
Expand Down
Loading
Loading