From 2d8515db7e95a625d2dd891eefcb8132f1ff7abd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:33:14 -0700 Subject: [PATCH 1/5] ci: README invariants guard, with Discord in the hero (TASK-430) main's README dropped the Discord invite from the top when #2477 rewrote it, leaving it only in the footer. scripts/verify-readme-invariants.js fails, naming each missing item, unless the README carries the invite within its first 40 lines, a docs link, a LICENSE link, and a quickstart. It runs with a self-test (a README without the invite must fail naming it) in the tool-tests job, next to the other verify steps. The README's hero now links Discord, so the guard passes on the head; main's README fails the same check. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- .github/workflows/tests.yml | 1 + README.md | 2 +- package.json | 1 + scripts/verify-readme-invariants.js | 97 +++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-readme-invariants.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7a92996ae..2361e02707 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -155,6 +155,7 @@ jobs: - run: cd workers/agent-runtime && npm ci --include=dev - run: npm run verify:moltbot-tools - run: npm run verify:litellm-patch-runner + - run: npm run verify:readme-invariants # These node:test files lived only in the retired all-in-one job. - name: Run agent service tests run: | diff --git a/README.md b/README.md index 0d168b4d5a..782171d553 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Bring your workflow and your agents.
Your team works with them in one pod. -**[Try the cloud agent →](https://commonly.me)**     **[Bring your own →](#bring-your-own)** +**[Try the cloud agent →](https://commonly.me)**     **[Bring your own →](#bring-your-own)**     **[Discord →](https://discord.gg/NsS3fzsJDw)** A Commonly pod: Sam, Wren and Kai in one thread, with Wren's decision card asking whether to email affected users now. diff --git a/package.json b/package.json index b5b87ab584..92de278f15 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "verify:litellm-patch-runner": "node scripts/verify-litellm-patch-runner.js", "verify:numbered-rules": "node scripts/verify-numbered-rules.js", "verify:ax-audit-numbering": "node scripts/verify-ax-audit-numbering.js", + "verify:readme-invariants": "node scripts/verify-readme-invariants.js && node scripts/verify-readme-invariants.js --self-test", "prepare": "husky" }, "devDependencies": { diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js new file mode 100644 index 0000000000..f50d8cc27c --- /dev/null +++ b/scripts/verify-readme-invariants.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * README invariant check. + * + * The official Discord invite went into the README with a top badge on 07-19 + * (#694, #695). The 10-23 rewrite (#2477) dropped the badge, so Discord + * survived only as a footer link, and nothing noticed until Sam did. This check + * is the thing that notices: it fails, naming each missing item, when the + * README stops carrying what a visitor needs. + * + * The rules are below. To change them, edit RULES here and record the reason in + * docs/design/readme-benchmark.md (TASK-422), which is where the README's + * structure is argued. Do not add a rule without a reason there. + * + * Usage: node scripts/verify-readme-invariants.js [path] + * node scripts/verify-readme-invariants.js --self-test + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_FILE = path.join(__dirname, '..', 'README.md'); +const TOP_LINES = 40; +const DISCORD_INVITE = 'https://discord.gg/NsS3fzsJDw'; + +// Each rule: a name a reader can act on, and a test over the README text. +const RULES = [ + { + name: `the Discord invite (${DISCORD_INVITE}) within the first ${TOP_LINES} lines`, + check: (lines) => lines.slice(0, TOP_LINES).join('\n').includes(DISCORD_INVITE), + }, + { + name: 'a link to the docs', + check: (lines) => /\]\((docs\/|https:\/\/docs\.commonly\.me)/.test(lines.join('\n')), + }, + { + name: 'a link to the license (LICENSE)', + check: (lines) => /\]\((\.\/)?LICENSE\)/.test(lines.join('\n')), + }, + { + name: 'a quickstart section or link', + check: (lines) => /^#{1,6} .*quick ?start/im.test(lines.join('\n')) + || /\]\([^)]*quickstart[^)]*\)/i.test(lines.join('\n')), + }, +]; + +function checkReadme(text) { + const lines = text.split('\n'); + return RULES.filter((rule) => !rule.check(lines)).map((rule) => `missing ${rule.name}`); +} + +function selfTest() { + const complete = [ + `[![Discord](https://img.shields.io/badge/chat-discord-blue)](${DISCORD_INVITE})`, + '## Quick Start', + '[Docs](docs/README.md) · [License](LICENSE)', + ].join('\n'); + if (checkReadme(complete).length !== 0) { + throw new Error('self-test: a complete fixture was reported as missing something'); + } + + // A README without the invite must fail, and must name the invite. + const noInvite = complete.replace(DISCORD_INVITE, 'https://example.com'); + const problems = checkReadme(noInvite); + if (!problems.some((p) => p.includes(DISCORD_INVITE))) { + throw new Error(`self-test: a README without the invite did not fail naming it: ${JSON.stringify(problems)}`); + } + + // The invite past line 40 is a footer, not a top link: it must fail too. + const padded = `${'filler\n'.repeat(TOP_LINES)}${DISCORD_INVITE}\n## Quick Start\n[D](docs/x.md)\n[L](LICENSE)`; + if (!checkReadme(padded).some((p) => p.includes(`within the first ${TOP_LINES} lines`))) { + throw new Error('self-test: an invite below the top lines was accepted'); + } + console.log('readme invariants self-test: ok'); +} + +function main(argv) { + if (argv.includes('--self-test')) { + selfTest(); + return 0; + } + const file = argv.find((a) => !a.startsWith('--')) || DEFAULT_FILE; + const problems = checkReadme(fs.readFileSync(file, 'utf8')); + if (problems.length > 0) { + console.error(`README invariants failed for ${path.relative(process.cwd(), file)}:`); + for (const p of problems) console.error(` - ${p}`); + return 1; + } + console.log(`README invariants: ok (${RULES.length} rules)`); + return 0; +} + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} + +module.exports = { checkReadme, RULES, DISCORD_INVITE }; From e6b87052b44e2d021bf8c09c185abc11c2b0298e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:44:47 -0700 Subject: [PATCH 2/5] fix(ci): README guard names no missing doc path (TASK-430) The guard's comment pointed at docs/design/readme-benchmark.md, which is not on main yet (it lands with TASK-422), and its self-test fixture linked docs/x.md. The comment now names the brief by its row, and the fixture links the hosted quickstart, which the docs rule accepts. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/verify-readme-invariants.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js index f50d8cc27c..4d1e73c9c3 100644 --- a/scripts/verify-readme-invariants.js +++ b/scripts/verify-readme-invariants.js @@ -9,7 +9,7 @@ * README stops carrying what a visitor needs. * * The rules are below. To change them, edit RULES here and record the reason in - * docs/design/readme-benchmark.md (TASK-422), which is where the README's + * the README benchmark brief (TASK-422), which is where the README's * structure is argued. Do not add a rule without a reason there. * * Usage: node scripts/verify-readme-invariants.js [path] @@ -67,7 +67,7 @@ function selfTest() { } // The invite past line 40 is a footer, not a top link: it must fail too. - const padded = `${'filler\n'.repeat(TOP_LINES)}${DISCORD_INVITE}\n## Quick Start\n[D](docs/x.md)\n[L](LICENSE)`; + const padded = `${'filler\n'.repeat(TOP_LINES)}${DISCORD_INVITE}\n## Quick Start\n[D](https://docs.commonly.me/quickstart)\n[L](LICENSE)`; if (!checkReadme(padded).some((p) => p.includes(`within the first ${TOP_LINES} lines`))) { throw new Error('self-test: an invite below the top lines was accepted'); } From 9ac61dd69a78ff7cf9390cfb7089e3b3ab419823 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:50:41 -0700 Subject: [PATCH 3/5] fix(ci): match the Discord invite by parsed host and path (TASK-430) CodeQL js/incomplete-url-substring-sanitization flagged the substring check on PR #2570. Parse each URL in the first 40 lines and compare protocol, hostname and pathname; add a self-test for a lookalike URL that carries the invite in a query string. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/verify-readme-invariants.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js index 4d1e73c9c3..b192c2efce 100644 --- a/scripts/verify-readme-invariants.js +++ b/scripts/verify-readme-invariants.js @@ -23,11 +23,28 @@ const DEFAULT_FILE = path.join(__dirname, '..', 'README.md'); const TOP_LINES = 40; const DISCORD_INVITE = 'https://discord.gg/NsS3fzsJDw'; +// Compare parsed URLs, not substrings: `includes(url)` would accept +// `https://evil.example/?x=https://discord.gg/NsS3fzsJDw`. +function hasDiscordInvite(lines) { + const urls = lines.join('\n').match(/https?:\/\/[^\s)"'<>]+/g) || []; + const invite = new URL(DISCORD_INVITE); + return urls.some((raw) => { + try { + const url = new URL(raw); + return url.protocol === invite.protocol + && url.hostname === invite.hostname + && url.pathname === invite.pathname; + } catch (err) { + return false; + } + }); +} + // Each rule: a name a reader can act on, and a test over the README text. const RULES = [ { name: `the Discord invite (${DISCORD_INVITE}) within the first ${TOP_LINES} lines`, - check: (lines) => lines.slice(0, TOP_LINES).join('\n').includes(DISCORD_INVITE), + check: (lines) => hasDiscordInvite(lines.slice(0, TOP_LINES)), }, { name: 'a link to the docs', @@ -66,6 +83,12 @@ function selfTest() { throw new Error(`self-test: a README without the invite did not fail naming it: ${JSON.stringify(problems)}`); } + // The invite carried in another host's query string is not the invite. + const lookalike = complete.replace(DISCORD_INVITE, `https://example.com/?u=${DISCORD_INVITE}`); + if (!checkReadme(lookalike).some((p) => p.includes(DISCORD_INVITE))) { + throw new Error('self-test: a lookalike URL carrying the invite was accepted'); + } + // The invite past line 40 is a footer, not a top link: it must fail too. const padded = `${'filler\n'.repeat(TOP_LINES)}${DISCORD_INVITE}\n## Quick Start\n[D](https://docs.commonly.me/quickstart)\n[L](LICENSE)`; if (!checkReadme(padded).some((p) => p.includes(`within the first ${TOP_LINES} lines`))) { From f530526962cfef7e19be050accbeb47c8ceacd9c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:55:50 -0700 Subject: [PATCH 4/5] fix(ci): name the invite by label, not the URL, in messages (TASK-430) CodeQL still flagged the self-test substring checks on the full URL. Match the rule name on the bare host/path label instead; URL matching is unchanged. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/verify-readme-invariants.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js index b192c2efce..33bb8db4ca 100644 --- a/scripts/verify-readme-invariants.js +++ b/scripts/verify-readme-invariants.js @@ -22,6 +22,8 @@ const path = require('path'); const DEFAULT_FILE = path.join(__dirname, '..', 'README.md'); const TOP_LINES = 40; const DISCORD_INVITE = 'https://discord.gg/NsS3fzsJDw'; +// The bare host/path, for messages and self-test labels. Never used to match a URL. +const DISCORD_LABEL = 'discord.gg/NsS3fzsJDw'; // Compare parsed URLs, not substrings: `includes(url)` would accept // `https://evil.example/?x=https://discord.gg/NsS3fzsJDw`. @@ -43,7 +45,7 @@ function hasDiscordInvite(lines) { // Each rule: a name a reader can act on, and a test over the README text. const RULES = [ { - name: `the Discord invite (${DISCORD_INVITE}) within the first ${TOP_LINES} lines`, + name: `the Discord invite (${DISCORD_LABEL}) within the first ${TOP_LINES} lines`, check: (lines) => hasDiscordInvite(lines.slice(0, TOP_LINES)), }, { @@ -79,13 +81,13 @@ function selfTest() { // A README without the invite must fail, and must name the invite. const noInvite = complete.replace(DISCORD_INVITE, 'https://example.com'); const problems = checkReadme(noInvite); - if (!problems.some((p) => p.includes(DISCORD_INVITE))) { + if (!problems.some((p) => p.includes(DISCORD_LABEL))) { throw new Error(`self-test: a README without the invite did not fail naming it: ${JSON.stringify(problems)}`); } // The invite carried in another host's query string is not the invite. const lookalike = complete.replace(DISCORD_INVITE, `https://example.com/?u=${DISCORD_INVITE}`); - if (!checkReadme(lookalike).some((p) => p.includes(DISCORD_INVITE))) { + if (!checkReadme(lookalike).some((p) => p.includes(DISCORD_LABEL))) { throw new Error('self-test: a lookalike URL carrying the invite was accepted'); } From 73a1d25c0049c44ef0bc809dff51e5a2ae7fa8a1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:02:34 -0700 Subject: [PATCH 5/5] fix(ci): parse docs link targets so userinfo and lookalike hosts fail (TASK-430) Lark's HOLD on 0bbef101: a prefix match accepted https://docs.commonly.me@evil.example/ and a lookalike host. Parse each link target and require https with hostname docs.commonly.me, or a relative docs/ path. Self-test covers both bypasses. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- scripts/verify-readme-invariants.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js index 33bb8db4ca..19120f1500 100644 --- a/scripts/verify-readme-invariants.js +++ b/scripts/verify-readme-invariants.js @@ -42,6 +42,22 @@ function hasDiscordInvite(lines) { }); } +// Link targets are parsed, not matched as prefixes: a prefix test accepts +// `https://docs.commonly.me@evil.example/` and `https://docs.commonly.me.evil.example/`. +function linkTargets(lines) { + return [...lines.join('\n').matchAll(/\]\(([^)\s]+)/g)].map((m) => m[1]); +} + +function isDocsTarget(target) { + if (target.startsWith('docs/')) return true; + try { + const url = new URL(target); + return url.protocol === 'https:' && url.hostname === 'docs.commonly.me'; + } catch (err) { + return false; + } +} + // Each rule: a name a reader can act on, and a test over the README text. const RULES = [ { @@ -50,7 +66,7 @@ const RULES = [ }, { name: 'a link to the docs', - check: (lines) => /\]\((docs\/|https:\/\/docs\.commonly\.me)/.test(lines.join('\n')), + check: (lines) => linkTargets(lines).some(isDocsTarget), }, { name: 'a link to the license (LICENSE)', @@ -85,6 +101,14 @@ function selfTest() { throw new Error(`self-test: a README without the invite did not fail naming it: ${JSON.stringify(problems)}`); } + // A docs link must resolve to the docs host, not merely start with it. + for (const bogus of ['https://docs.commonly.me@evil.example/', 'https://docs.commonly.me.evil.example/']) { + const fake = complete.replace('[Docs](docs/README.md)', `[Docs](${bogus})`); + if (!checkReadme(fake).some((p) => p.includes('a link to the docs'))) { + throw new Error(`self-test: a docs link to ${bogus} was accepted`); + } + } + // The invite carried in another host's query string is not the invite. const lookalike = complete.replace(DISCORD_INVITE, `https://example.com/?u=${DISCORD_INVITE}`); if (!checkReadme(lookalike).some((p) => p.includes(DISCORD_LABEL))) {