Skip to content

Commit d7c05be

Browse files
authored
fix(release): upload ClawHub trust artifacts as text (#301)
* fix(release): upload trust artifacts as text * fix(release): reject ClawHub test files * fix(release): share test file filtering
1 parent fe7e744 commit d7c05be

8 files changed

Lines changed: 247 additions & 23 deletions

scripts/ci/clawhub_release_package.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "node:fs/promises";
1515
import path from "node:path";
1616
import { pathToFileURL } from "node:url";
17+
import { isTestReleasePath } from "./release_path_policy.mjs";
1718

1819
const CLAWHUB_TEXT_EXTENSIONS = new Set([
1920
"c", "cfg", "cjs", "cpp", "cs", "css", "csv", "env", "go", "h", "hpp",
@@ -28,6 +29,7 @@ const ADVISORY_TRUST_ARTIFACTS = [
2829
"advisories/checksums.json.sig",
2930
"advisories/feed-signing-public.pem",
3031
];
32+
const CLAWHUB_TEXT_TRUST_EXTENSIONS = new Set(["pem", "sig"]);
3133

3234
function usage() {
3335
return [
@@ -160,6 +162,9 @@ export async function collectClawhubPackageFiles(rootDir) {
160162
const publishedFiles = new Map();
161163

162164
for (const [filePath, metadata] of packageFiles) {
165+
if (isTestReleasePath(filePath)) {
166+
throw new Error(`ClawHub package must not contain test-only file: ${filePath}`);
167+
}
163168
const parts = filePath.split("/");
164169
const extension = path.posix.extname(filePath).slice(1).toLowerCase();
165170
const clientWouldOmit = parts.some((part) => part.startsWith("."))
@@ -396,6 +401,17 @@ export async function verifyClawhubClientSelection({ packageDir, cliPrefix }) {
396401
}
397402

398403
const selectedFiles = await skillsModule.listTextFiles(resolvedPackageDir);
404+
for (const entry of selectedFiles) {
405+
const extension = entry.relPath.split(".").at(-1)?.toLowerCase() ?? "";
406+
if (
407+
CLAWHUB_TEXT_TRUST_EXTENSIONS.has(extension)
408+
&& entry.contentType !== "text/plain"
409+
) {
410+
throw new Error(
411+
`ClawHub client would upload ${entry.relPath} with non-text MIME type: ${entry.contentType ?? "missing"}`,
412+
);
413+
}
414+
}
399415
const actualFiles = new Map(selectedFiles.map((entry) => [entry.relPath, {
400416
path: entry.relPath,
401417
sha256: sha256(Buffer.from(entry.bytes)),

scripts/ci/patch_clawhub_trust_extensions.mjs

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import { pathToFileURL } from "node:url";
77

88
const REQUIRED_TRUST_EXTENSIONS = ["pem", "sig"];
9+
const REQUIRED_TRUST_CONTENT_TYPE = "text/plain";
910

1011
function resolveTextFilesPath(cliPrefix) {
1112
return path.join(
@@ -18,17 +19,21 @@ function resolveTextFilesPath(cliPrefix) {
1819
);
1920
}
2021

22+
function resolveSkillsPath(cliPrefix) {
23+
return path.join(
24+
path.resolve(cliPrefix),
25+
"node_modules",
26+
"clawhub",
27+
"dist",
28+
"skills.js",
29+
);
30+
}
31+
2132
function extensionPattern(extension) {
2233
return new RegExp(`["']${extension}["']\\s*,`);
2334
}
2435

25-
export async function patchClawhubTrustExtensions(cliPrefix) {
26-
const textFilesPath = resolveTextFilesPath(cliPrefix);
27-
if (!existsSync(textFilesPath)) {
28-
throw new Error(`ClawHub text-file schema not found: ${textFilesPath}`);
29-
}
30-
31-
const original = await readFile(textFilesPath, "utf8");
36+
function patchTextFileExtensions(original, textFilesPath) {
3237
const listPattern = /(const RAW_TEXT_FILE_EXTENSIONS\s*=\s*\[)([\s\S]*?)(\n\s*\];)/;
3338
const match = original.match(listPattern);
3439
if (!match) {
@@ -40,7 +45,7 @@ export async function patchClawhubTrustExtensions(cliPrefix) {
4045
(extension) => !extensionPattern(extension).test(entries),
4146
);
4247
if (missingExtensions.length === 0) {
43-
return { patched: false, path: textFilesPath, extensions: REQUIRED_TRUST_EXTENSIONS };
48+
return { source: original, patched: false };
4449
}
4550

4651
const indentation = entries.match(/\n([ \t]+)["']/)?.[1] ?? " ";
@@ -58,8 +63,62 @@ export async function patchClawhubTrustExtensions(cliPrefix) {
5863
}
5964
}
6065

61-
await writeFile(textFilesPath, patched, "utf8");
62-
return { patched: true, path: textFilesPath, extensions: REQUIRED_TRUST_EXTENSIONS };
66+
return { source: patched, patched: true };
67+
}
68+
69+
function patchTrustMimeTypes(original, skillsPath) {
70+
const originalAssignment = /const contentType = mime\.getType\(relPath\) \?\? (["'])text\/plain\1;/;
71+
const extensions = JSON.stringify(REQUIRED_TRUST_EXTENSIONS);
72+
// ClawHub validates upload MIME types separately from its extension allowlist.
73+
// The mime package classifies .sig/.pem as non-text even though these files are ASCII here.
74+
const patchedAssignment = `const contentType = ${extensions}.includes(ext) ? "${REQUIRED_TRUST_CONTENT_TYPE}" : (mime.getType(relPath) ?? "text/plain");`;
75+
76+
if (original.includes(patchedAssignment)) {
77+
return { source: original, patched: false };
78+
}
79+
if (!originalAssignment.test(original)) {
80+
throw new Error(`Could not find ClawHub MIME assignment in ${skillsPath}`);
81+
}
82+
83+
const patched = original.replace(originalAssignment, patchedAssignment);
84+
if (!patched.includes(patchedAssignment)) {
85+
throw new Error(`Failed to force trust artifacts to ${REQUIRED_TRUST_CONTENT_TYPE}`);
86+
}
87+
return { source: patched, patched: true };
88+
}
89+
90+
export async function patchClawhubTrustExtensions(cliPrefix) {
91+
const textFilesPath = resolveTextFilesPath(cliPrefix);
92+
const skillsPath = resolveSkillsPath(cliPrefix);
93+
if (!existsSync(textFilesPath)) {
94+
throw new Error(`ClawHub text-file schema not found: ${textFilesPath}`);
95+
}
96+
if (!existsSync(skillsPath)) {
97+
throw new Error(`ClawHub skill collector not found: ${skillsPath}`);
98+
}
99+
100+
const textFilesPatch = patchTextFileExtensions(
101+
await readFile(textFilesPath, "utf8"),
102+
textFilesPath,
103+
);
104+
const skillsPatch = patchTrustMimeTypes(
105+
await readFile(skillsPath, "utf8"),
106+
skillsPath,
107+
);
108+
if (textFilesPatch.patched) {
109+
await writeFile(textFilesPath, textFilesPatch.source, "utf8");
110+
}
111+
if (skillsPatch.patched) {
112+
await writeFile(skillsPath, skillsPatch.source, "utf8");
113+
}
114+
115+
return {
116+
patched: textFilesPatch.patched || skillsPatch.patched,
117+
path: textFilesPath,
118+
paths: { textFiles: textFilesPath, skills: skillsPath },
119+
extensions: REQUIRED_TRUST_EXTENSIONS,
120+
contentType: REQUIRED_TRUST_CONTENT_TYPE,
121+
};
63122
}
64123

65124
async function main() {
@@ -72,7 +131,9 @@ async function main() {
72131
: process.argv[prefixIndex + 1];
73132
const result = await patchClawhubTrustExtensions(cliPrefix);
74133
const action = result.patched ? "Patched" : "Already patched";
75-
process.stdout.write(`[patch-clawhub-trust] ${action}: ${result.path}\n`);
134+
process.stdout.write(
135+
`[patch-clawhub-trust] ${action}: ${result.paths.textFiles}, ${result.paths.skills}\n`,
136+
);
76137
}
77138

78139
const invokedUrl = process.argv[1]

scripts/ci/release_path_policy.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function isTestReleasePath(filePath) {
2+
const normalized = filePath.replaceAll("\\", "/").toLowerCase();
3+
const parts = normalized.split("/");
4+
const name = parts.at(-1) ?? "";
5+
return parts.some((part) => ["__tests__", "test", "tests"].includes(part))
6+
|| /^(?:test|spec)[_-]/.test(name)
7+
|| /\.(?:test|spec)\./.test(name);
8+
}

scripts/ci/simulate_skill_tag_release.mjs

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
1414
import { tmpdir } from "node:os";
1515
import path from "node:path";
1616
import { nextSimulatedReleaseVersion } from "./semver_increment.mjs";
17+
import { isTestReleasePath } from "./release_path_policy.mjs";
1718

1819
const TRUST_ARTIFACTS = [
1920
"skill-card.md",
@@ -120,16 +121,6 @@ function normalizeReleasePath(rawPath) {
120121
return releasePath;
121122
}
122123

123-
function isTestReleasePath(releasePath) {
124-
const lower = releasePath.toLowerCase();
125-
return lower === "test" ||
126-
lower === "tests" ||
127-
lower.startsWith("test/") ||
128-
lower.startsWith("tests/") ||
129-
lower.includes("/test/") ||
130-
lower.includes("/tests/");
131-
}
132-
133124
async function sha256File(filePath) {
134125
const buffer = await readFile(filePath);
135126
return createHash("sha256").update(buffer).digest("hex");
@@ -416,7 +407,7 @@ async function main() {
416407
});
417408

418409
const zipContents = run("unzip", ["-Z1", path.join(releaseAssetsDir, zipName)]);
419-
if (zipContents.split("\n").some((entry) => /(^|\/)(test|tests)\//i.test(entry))) {
410+
if (zipContents.split("\n").some(isTestReleasePath)) {
420411
throw new Error(`Simulated release archive contains test-only files: ${zipName}`);
421412
}
422413

scripts/test-skill-clawhub-trust-extensions.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawhub-trust-extensions-
1515
try {
1616
const schemaDir = path.join(tempRoot, "node_modules", "clawhub", "dist", "schema");
1717
const schemaPath = path.join(schemaDir, "textFiles.js");
18+
const skillsPath = path.join(tempRoot, "node_modules", "clawhub", "dist", "skills.js");
1819
await mkdir(schemaDir, { recursive: true });
1920
await writeFile(path.join(tempRoot, "node_modules", "clawhub", "package.json"), '{"type":"module"}\n');
2021
await writeFile(
@@ -29,16 +30,31 @@ try {
2930
"",
3031
].join("\n"),
3132
);
33+
await writeFile(
34+
skillsPath,
35+
[
36+
"const ext = relPath.split('.').at(-1)?.toLowerCase() ?? '';",
37+
"const contentType = mime.getType(relPath) ?? 'text/plain';",
38+
"",
39+
].join("\n"),
40+
);
3241

3342
const first = await patchClawhubTrustExtensions(tempRoot);
3443
assert.equal(first.patched, true);
3544
const patchedSource = await readFile(schemaPath, "utf8");
45+
const patchedSkillsSource = await readFile(skillsPath, "utf8");
3646
assert.match(patchedSource, /'pem',/);
3747
assert.match(patchedSource, /'sig',/);
48+
assert.match(
49+
patchedSkillsSource,
50+
/\["pem","sig"\]\.includes\(ext\) \? "text\/plain"/,
51+
"The patched client must upload trust extensions as text/plain",
52+
);
3853

3954
const second = await patchClawhubTrustExtensions(tempRoot);
4055
assert.equal(second.patched, false, "ClawHub extension patch must be idempotent");
4156
assert.equal(await readFile(schemaPath, "utf8"), patchedSource);
57+
assert.equal(await readFile(skillsPath, "utf8"), patchedSkillsSource);
4258

4359
const schema = await import(`${pathToFileURL(schemaPath).href}?test=${Date.now()}`);
4460
assert.equal(schema.TEXT_FILE_EXTENSION_SET.has("pem"), true);
@@ -73,6 +89,23 @@ try {
7389
);
7490
await rm(path.join(packageDir, "runtime.bin"));
7591

92+
await writeFile(path.join(packageDir, "test_scanner_fixture.py"), "raise AssertionError\n");
93+
await assert.rejects(
94+
collectClawhubPackageFiles(packageDir),
95+
/must not contain test-only file: test_scanner_fixture\.py/,
96+
"ClawHub package preparation must reject test-named files before upload",
97+
);
98+
await rm(path.join(packageDir, "test_scanner_fixture.py"));
99+
100+
await mkdir(path.join(packageDir, "tests"));
101+
await writeFile(path.join(packageDir, "tests", "scanner-fixture.md"), "# Test fixture\n");
102+
await assert.rejects(
103+
collectClawhubPackageFiles(packageDir),
104+
/must not contain test-only file: tests\/scanner-fixture\.md/,
105+
"ClawHub package preparation must reject files in test directories before upload",
106+
);
107+
await rm(path.join(packageDir, "tests"), { recursive: true });
108+
76109
const installedClientSource = path.resolve(".github", "clawhub-cli");
77110
const installedSkillsModule = path.join(
78111
installedClientSource,
@@ -82,9 +115,40 @@ try {
82115
"skills.js",
83116
);
84117
if (existsSync(installedSkillsModule)) {
118+
const mimeFailureCopy = path.join(tempRoot, "mime-failure-clawhub-client");
119+
await cp(installedClientSource, mimeFailureCopy, { recursive: true });
120+
await patchClawhubTrustExtensions(mimeFailureCopy);
121+
await cp(
122+
installedSkillsModule,
123+
path.join(mimeFailureCopy, "node_modules", "clawhub", "dist", "skills.js"),
124+
);
125+
await assert.rejects(
126+
verifyClawhubClientSelection({
127+
packageDir,
128+
cliPrefix: mimeFailureCopy,
129+
}),
130+
/non-text MIME type: application\//,
131+
"The release simulation must reproduce ClawHub's rejection of non-text trust MIME types",
132+
);
133+
85134
const installedClientCopy = path.join(tempRoot, "installed-clawhub-client");
86135
await cp(installedClientSource, installedClientCopy, { recursive: true });
87136
await patchClawhubTrustExtensions(installedClientCopy);
137+
const installedSkills = await import(
138+
`${pathToFileURL(path.join(installedClientCopy, "node_modules", "clawhub", "dist", "skills.js")).href}?mime=${Date.now()}`
139+
);
140+
const trustFiles = (await installedSkills.listTextFiles(packageDir))
141+
.filter((entry) => /\.(?:pem|sig)$/.test(entry.relPath));
142+
assert.deepEqual(
143+
trustFiles
144+
.map((entry) => ({ path: entry.relPath, contentType: entry.contentType }))
145+
.sort((left, right) => left.path.localeCompare(right.path)),
146+
[
147+
{ path: "key.pem", contentType: "text/plain" },
148+
{ path: "signature.sig", contentType: "text/plain" },
149+
],
150+
"The patched pinned ClawHub client must send trust files as text/plain",
151+
);
88152
assert.deepEqual(
89153
await verifyClawhubClientSelection({
90154
packageDir,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import assert from "node:assert/strict";
2+
import { isTestReleasePath } from "./ci/release_path_policy.mjs";
3+
4+
for (const testOnlyPath of [
5+
"test/helper.py",
6+
"tests/helper.py",
7+
"__tests__/helper.js",
8+
"lib/test/helper.py",
9+
"lib/tests/helper.py",
10+
"lib/__tests__/helper.js",
11+
"test_helper.py",
12+
"test-helper.py",
13+
"spec_helper.py",
14+
"spec-helper.py",
15+
"helper.test.mjs",
16+
"helper.spec.mjs",
17+
"TESTS\\helper.py",
18+
]) {
19+
assert.equal(isTestReleasePath(testOnlyPath), true, `expected test-only path: ${testOnlyPath}`);
20+
}
21+
22+
for (const releasePath of [
23+
"SKILL.md",
24+
"attestation/verify.mjs",
25+
"contest/data.md",
26+
"latest/feed.json",
27+
"testing/guide.md",
28+
]) {
29+
assert.equal(isTestReleasePath(releasePath), false, `expected release path: ${releasePath}`);
30+
}
31+
32+
console.log("Release test-path policy tests passed");

scripts/test-skill-release-workflow.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,12 @@ for (const extension of ['pem', 'sig']) {
637637
);
638638
}
639639

640+
assert.match(
641+
patchClawhubTrustExtensions,
642+
/REQUIRED_TRUST_CONTENT_TYPE = "text\/plain"/,
643+
'ClawHub trust-extension patch must send ASCII trust artifacts with a server-accepted text MIME type',
644+
);
645+
640646
assert.match(
641647
patchClawhubTrustExtensions,
642648
/Already patched/,

0 commit comments

Comments
 (0)