diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index d31b8354fa9a..4d734fb95c28 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -267,6 +267,8 @@ jobs: }, ...(Array.isArray(manifest.dependencyTarballs) ? manifest.dependencyTarballs : []), ]; + const packageNames = new Set(); + const tarballNames = new Set(); for (const entry of entries) { if ( !entry.packageName || @@ -277,6 +279,11 @@ jobs: ) { throw new Error("package artifact manifest contains invalid tarball metadata"); } + if (packageNames.has(entry.packageName) || tarballNames.has(entry.tarballName)) { + throw new Error("package artifact manifest contains duplicate package metadata"); + } + packageNames.add(entry.packageName); + tarballNames.add(entry.tarballName); const tarballPath = path.join(packageDir, entry.tarballName); if (!fs.existsSync(tarballPath)) { throw new Error(`package artifact is missing ${entry.tarballName}`); @@ -294,6 +301,16 @@ jobs: throw new Error(`package artifact metadata mismatch for ${entry.packageName}`); } } + const artifactTarballs = fs + .readdirSync(packageDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".tgz")) + .map((entry) => entry.name); + if ( + artifactTarballs.length !== tarballNames.size || + artifactTarballs.some((name) => !tarballNames.has(name)) + ) { + throw new Error("package artifact tarball set does not match preflight manifest"); + } process.stdout.write(path.join(packageDir, manifest.tarballName)); NODE )" diff --git a/scripts/e2e/lib/plugins/npm-registry-server.mjs b/scripts/e2e/lib/plugins/npm-registry-server.mjs index 0fc7a14eb311..df4a5d41917c 100644 --- a/scripts/e2e/lib/plugins/npm-registry-server.mjs +++ b/scripts/e2e/lib/plugins/npm-registry-server.mjs @@ -114,8 +114,10 @@ async function proxyUpstream(url, response) { const upstreamUrl = new URL(`${url.pathname}${url.search}`, `${upstreamRegistry}/`); const upstreamResponse = await fetch(upstreamUrl, { redirect: "manual" }); const body = Buffer.from(await upstreamResponse.arrayBuffer()); - const headers = {}; - for (const name of ["content-length", "content-type", "location"]) { + // Fetch decodes compressed bodies but preserves upstream length metadata. + // Emit the decoded size so npm clients do not truncate proxied responses. + const headers = { "content-length": String(body.length) }; + for (const name of ["content-type", "location"]) { const value = upstreamResponse.headers.get(name); if (value) { headers[name] = value; diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index e4de6396d962..f0d074169eb2 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1785,6 +1785,8 @@ describe("package artifact reuse", () => { 'manifest="${package_dir}/preflight-manifest.json"', 'candidate_manifest="${package_dir}/package-candidate.json"', 'find "${package_dir}" -type f -name "*.tgz"', + "package artifact manifest contains duplicate package metadata", + "package artifact tarball set does not match preflight manifest", "package candidate manifest does not match the OpenClaw tarball", "package candidate digest mismatch", 'export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}"', diff --git a/test/scripts/plugins-assertions.test.ts b/test/scripts/plugins-assertions.test.ts index 0314ce639b47..59c63a713224 100644 --- a/test/scripts/plugins-assertions.test.ts +++ b/test/scripts/plugins-assertions.test.ts @@ -12,6 +12,7 @@ import { import { createServer, request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { createBoundedChildOutput } from "../helpers/bounded-child-output.js"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; @@ -160,7 +161,7 @@ function requestFixtureRegistry( port: number, requestPath: string, headers: Record = {}, -): Promise<{ body: string; statusCode: number | undefined }> { +): Promise<{ body: string; contentLength: string | undefined; statusCode: number | undefined }> { return new Promise((resolve, reject) => { const request = httpRequest( { headers, host: "127.0.0.1", method: "GET", path: requestPath, port }, @@ -171,7 +172,11 @@ function requestFixtureRegistry( body += chunk; }); response.on("end", () => { - resolve({ body, statusCode: response.statusCode }); + resolve({ + body, + contentLength: response.headers["content-length"], + statusCode: response.statusCode, + }); }); }, ); @@ -670,6 +675,71 @@ test -d "$OPENCLAW_PLUGINS_TMP_DIR" } }); + it("recomputes proxied content length after fetch decodes the response", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + const upstreamBody = JSON.stringify({ payload: "x".repeat(1_000) }); + const compressedBody = gzipSync(upstreamBody); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { + "content-encoding": "gzip", + "content-length": String(compressedBody.length), + "content-type": "application/json", + }); + response.end(compressedBody); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/upstream-package"); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(upstreamBody); + expect(response.contentLength).toBe(String(Buffer.byteLength(upstreamBody))); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + cleanupTempDirs(tempDirs); + } + }); + it("rejects invalid plugin fixture log byte limits before npm fixture setup", () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-npm-fixture-log-invalid-")); try {