Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
8 changes: 6 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import type * as lib_emailRendering from "../lib/emailRendering.js";
import type * as lib_emails from "../lib/emails.js";
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_experimentalClaws from "../lib/experimentalClaws.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
import type * as lib_githubAuth from "../lib/githubAuth.js";
Expand Down Expand Up @@ -107,6 +108,7 @@ import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js
import type * as lib_publisherCatalogDisplay from "../lib/publisherCatalogDisplay.js";
import type * as lib_publisherStats from "../lib/publisherStats.js";
import type * as lib_publishers from "../lib/publishers.js";
import type * as lib_rankingMetricsImportLock from "../lib/rankingMetricsImportLock.js";
import type * as lib_recommendationScore from "../lib/recommendationScore.js";
import type * as lib_reporting from "../lib/reporting.js";
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
Expand Down Expand Up @@ -251,6 +253,7 @@ declare const fullApi: ApiFromModules<{
"lib/emails": typeof lib_emails;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/experimentalClaws": typeof lib_experimentalClaws;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
"lib/githubAuth": typeof lib_githubAuth;
Expand Down Expand Up @@ -289,6 +292,7 @@ declare const fullApi: ApiFromModules<{
"lib/publisherCatalogDisplay": typeof lib_publisherCatalogDisplay;
"lib/publisherStats": typeof lib_publisherStats;
"lib/publishers": typeof lib_publishers;
"lib/rankingMetricsImportLock": typeof lib_rankingMetricsImportLock;
"lib/recommendationScore": typeof lib_recommendationScore;
"lib/reporting": typeof lib_reporting;
"lib/reservedHandles": typeof lib_reservedHandles;
Expand Down
246 changes: 246 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12191,6 +12191,7 @@ describe("httpApiV1 handlers", () => {
artifactKind: "legacy-zip",
integritySha256: "a".repeat(64),
sha256hash: "b".repeat(64),
clawpackSize: 321,
},
};
}
Expand All @@ -12214,6 +12215,7 @@ describe("httpApiV1 handlers", () => {
source: "clawhub",
artifactKind: "legacy-zip",
artifactSha256: "b".repeat(64),
size: 321,
packageName: "demo-plugin",
version: "1.0.0",
downloadUrl: "https://example.com/api/v1/packages/demo-plugin/download?version=1.0.0",
Expand Down Expand Up @@ -14444,6 +14446,126 @@ describe("httpApiV1 handlers", () => {
expect(storageGet).toHaveBeenCalledWith("storage:1");
});

it("serves the immutable stored legacy archive without reconstructing it", async () => {
const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xaa, 0xbb]);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: null,
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
artifactKind: "legacy-zip",
clawpackStorageId: "storage:archive",
files: [
{
path: "package.json",
size: 2,
sha256: "a".repeat(64),
storageId: "storage:file-that-must-not-be-read",
},
],
};
}
return null;
});
const storageGet = vi.fn(async (id: string) =>
id === "storage:archive" ? new Blob([archive], { type: "application/zip" }) : null,
);

const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
);

expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(archive);
expect(storageGet).toHaveBeenCalledTimes(1);
expect(storageGet).toHaveBeenCalledWith("storage:archive");
});

it("reconstructs compatibility ZIP downloads instead of serving stored npm tarballs", async () => {
const storedTarball = gzipSync(new TextEncoder().encode("not a zip"));
const packageJson = new TextEncoder().encode('{"name":"demo-plugin"}');
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: null,
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
artifactKind: "npm-pack",
clawpackStorageId: "storage:tarball",
files: [
{
path: "package.json",
size: packageJson.byteLength,
sha256: "a".repeat(64),
storageId: "storage:package-json",
},
],
};
}
return null;
});
const storageGet = vi.fn(async (id: string) => {
if (id === "storage:tarball") return new Blob([storedTarball], { type: "application/gzip" });
if (id === "storage:package-json") return new Blob([packageJson]);
return null;
});

const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
);

expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/zip");
expect(
strFromU8(unzipSync(new Uint8Array(await response.arrayBuffer()))["package/package.json"]!),
).toBe('{"name":"demo-plugin"}');
expect(storageGet).not.toHaveBeenCalledWith("storage:tarball");
expect(storageGet).toHaveBeenCalledWith("storage:package-json");
});

it("allows package downloads when verification is clean even without cached vtAnalysis", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
Expand Down Expand Up @@ -14750,6 +14872,65 @@ describe("httpApiV1 handlers", () => {
expect(runAction).not.toHaveBeenCalled();
});

it.each(["loose", "direct-tgz", "staged-tgz"] as const)(
"disabled Claw publication rejects %s before multipart storage or ticket mutation",
async (mode) => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "0");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runAction = vi.fn();
const storageGet = vi.fn();
const storageStore = vi.fn();
const form = packagePublishForm(packagePublishMetadata({ family: "claw" }));
if (mode === "loose") {
form.append("files", new File(["manifest"], "CLAW.md", { type: "text/markdown" }));
} else if (mode === "direct-tgz") {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo-claw", version: "1.0.0" }),
"package/CLAW.md": "manifest",
});
form.append(
"clawpack",
new File([bytesToArrayBuffer(pack)], "demo-claw-1.0.0.tgz", {
type: "application/octet-stream",
}),
);
} else {
form.set("clawpack", "storage:clawpack");
form.set("clawpackUploadTicket", "packagePublishUploadTickets:1");
}

const response = await __handlers.publishPackageV1Handler(
makeCtx({
runAction,
runMutation,
storage: { get: storageGet, store: storageStore },
}),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);

expect(response.status).toBe(400);
expect(await response.text()).toBe("Experimental Claw publication is disabled");
expect(storageGet).not.toHaveBeenCalled();
expect(storageStore).not.toHaveBeenCalled();
expect(runAction).not.toHaveBeenCalled();
expect(
runMutation.mock.calls.some(([, args]) =>
Boolean(args && typeof args === "object" && "uploadTicket" in args),
),
).toBe(false);
},
);

it("package publish rejects browser session auth when token auth is not an API token", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:session" as never);
vi.mocked(requirePackagePublishAuth).mockRejectedValue(new Error("Unauthorized"));
Expand Down Expand Up @@ -14921,6 +15102,71 @@ describe("httpApiV1 handlers", () => {
expect(payload?.files?.map((file) => file.path)).toContain("dist/index.js");
});

it("multipart Claw publish accepts an npm pack without a plugin manifest", async () => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runAction = vi
.fn()
.mockResolvedValue({ ok: true, packageId: "pkg:claw", releaseId: "rel:claw" });
const storageStore = vi.fn(async () => `storage:${storageStore.mock.calls.length}`);
const pack = npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md":
"---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\nYou are a focused demo agent.\n",
});
const form = new FormData();
form.set(
"payload",
JSON.stringify({
name: "demo-claw",
family: "claw",
version: "1.0.0",
changelog: "init",
}),
);
form.append(
"clawpack",
new File([bytesToArrayBuffer(pack)], "demo-claw-1.0.0.tgz", {
type: "application/octet-stream",
}),
);

const response = await __handlers.publishPackageV1Handler(
makeCtx({ runAction, runMutation, storage: { store: storageStore } }),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);

expect(response.status, await response.clone().text()).toBe(200);
expect(storageStore).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
payload: expect.objectContaining({
family: "claw",
artifact: expect.objectContaining({ kind: "npm-pack", npmFileCount: 2 }),
files: [
expect.objectContaining({ path: "package.json" }),
expect.objectContaining({ path: "CLAW.md" }),
],
}),
}),
);
});

it("staged ClawPack publish derives artifact metadata from stored bytes", async () => {
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
Expand Down
Loading
Loading