Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
27f449f
test(fuzz): add the differential fuzz harness and the pure-helper fuzzer
claude Aug 9, 2026
879a03f
test(fuzz): add the protobuf codec differential fuzzer
claude Aug 10, 2026
78f1660
test(fuzz): add decoder robustness fuzzing under byte mutation
claude Aug 10, 2026
71af124
test(fuzz): generate the wire-fidelity cases from the schema
claude Aug 10, 2026
4623ea0
test(fuzz): fuzz the bridge anti-corruption layer and the event buffer
claude Aug 10, 2026
4edf11a
test(fuzz): fuzz the closed-domain argument boundary
claude Aug 10, 2026
6853d65
build(fuzz): wire the fuzz suite into CI, npm scripts and the docs
claude Aug 10, 2026
81998b7
test(fuzz): tighten the oracle after review
claude Aug 10, 2026
919a4b8
test(fuzz): credit the audit that already tracked the proto gaps
claude Aug 10, 2026
6996078
test(fuzz): fix a crash, an unreachable entry, and four blind spots
claude Aug 10, 2026
5b5347f
test(fuzz): make the trap guard cover every decode, and pin the packi…
claude Aug 10, 2026
208947a
test(fuzz): close six oracle holes found in review
claude Aug 10, 2026
86a9e02
test(fuzz): scope packability per message, not across the schema
claude Aug 10, 2026
a0c91bb
test(fuzz): make seven targets actually reach the code they name
claude Aug 10, 2026
2cedd89
test(fuzz): pin the not-encoded entry to the outcome it describes
claude Aug 10, 2026
be6c893
test(fuzz): stop the oracle guessing where the schema can tell it
claude Aug 10, 2026
efe09cd
test(fuzz): report the direction that was invisible, and stop the flakes
claude Aug 10, 2026
cedac01
test(fuzz): weight crypto lengths toward the ones the ciphers accept
claude Aug 10, 2026
c8f09c5
test(fuzz): make three targets run the code they claim to cover
claude Aug 10, 2026
f776a00
test(fuzz): stop four checks claiming more than they verified
claude Aug 10, 2026
627378e
test(fuzz): run the successful paths, and fail on lost coverage
claude Aug 10, 2026
a204a57
test(fuzz): make nine allowlist entries prove what they claim
claude Aug 10, 2026
b77946a
test(fuzz): repair a generator regression, and stop three loose matches
claude Aug 10, 2026
8956734
test(fuzz): stop the harness losing findings, and parse balanced groups
claude Aug 10, 2026
f4daa5a
test(fuzz): keep the signed zero, the array order, and the deep chains
claude Aug 10, 2026
229e634
test(fuzz): four more predicates that check the outcome, and a dead c…
claude Aug 10, 2026
9ef1534
test(fuzz): record why the oneof target does not compare ordering
claude Aug 10, 2026
5d40709
test(fuzz): stop three canonicalisations agreeing when they should not
claude Aug 10, 2026
d25c199
test(fuzz): anchor the hex fold, and make the oneof premise self-chec…
claude Aug 10, 2026
918f5a6
test(fuzz): compare repeated fields in order, and make three checks r…
claude Aug 10, 2026
20b5181
test(fuzz): order-preserving subset, schema-aware round trips, and a …
claude Aug 10, 2026
89bc565
test(fuzz): bound shrinking by the clock, and reach five branches not…
claude Aug 10, 2026
855cf1a
docs(fuzz): list what the suite does not cover, where planners will s…
claude Aug 10, 2026
6e83224
test(fuzz): narrow three allowlist entries, and reach four more branches
claude Aug 10, 2026
0d75d71
test(fuzz): compare oneof winners, and stop three oracles trusting th…
claude Aug 10, 2026
339c7c7
test(fuzz): compare the whole singleton encoding, and stop folding "0…
claude Aug 10, 2026
8b00212
test(fuzz): correlate buffered event identities, and find two merge-p…
claude Aug 10, 2026
03a58f9
test(fuzz): serialise reports without invoking the traps in what they…
claude Aug 10, 2026
cc26669
test(fuzz): let the round trip decide what the copy strategy explains
claude Aug 10, 2026
da42e1c
test(fuzz): drop two corpus files a measurement script left behind
claude Aug 10, 2026
d8ea5e1
test(fuzz): pin the omission and renumbering entries to what they mea…
claude Aug 10, 2026
fd6ab9e
test(fuzz): revert formatter churn in three unrelated audit files
claude Aug 10, 2026
0f92629
test(fuzz): make an unresolved schema path a finding, not a skip
claude Aug 10, 2026
7e9f5ab
test(fuzz): close three classifiers that excused what they should report
claude Aug 10, 2026
f07b816
test(fuzz): stop three checks from trusting a key they should not
claude Aug 10, 2026
bebf7a5
test(fuzz): stop three checks from normalising away their own subject
claude Aug 10, 2026
4418fd1
fix(fuzz): repair the renumbering classifier that was excusing nothing
claude Aug 10, 2026
5e60d67
fix(event-buffer): keep an id-less chats.upsert out of a history set
claude Aug 10, 2026
48b701c
test(fuzz): record findings, write failures and issue ownership honestly
claude Aug 10, 2026
94cc655
test(fuzz): bound crash details, and count them separately
claude Aug 10, 2026
62db42c
fix(fuzz): stop a clean seed closing an unresolved finding
claude Aug 10, 2026
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
144 changes: 144 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
name: Nightly Fuzz

# Two modes, on purpose.
#
# Every pull request already runs the fuzz suite through `npm test`, with a fixed
# seed and small per-target budgets. That run is deterministic: it cannot fail
# because of an unlucky draw, which is the only way a fuzz suite survives contact
# with a CI system people have to trust.
#
# This job is where the searching happens. It varies the seed per run, raises the
# budgets, and enforces the parts of the known-divergence registry that would be
# hostile on a pull request — entries past their review date, and entries that no
# longer excuse anything.

on:
schedule:
# 03:17 UTC, off the hour so it does not queue behind everything else.
- cron: '17 3 * * *'
workflow_dispatch:
inputs:
seed:
description: 'Fuzz seed (defaults to the run id)'
required: false
type: string
mode:
description: 'smoke or deep'
required: false
default: 'deep'
type: string

permissions:
contents: read
issues: write

jobs:
fuzz:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout code
uses: actions/checkout@v4
Comment thread
jlucaso1 marked this conversation as resolved.

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run the fuzz suite
id: fuzz
env:
# A fresh seed per run is the point: the fixed-seed smoke run on every
# PR has already searched its own corner exhaustively.
FUZZ_SEED: ${{ inputs.seed || github.run_id }}
FUZZ_MODE: ${{ inputs.mode || 'deep' }}
FUZZ_TIME_BUDGET_MS: '180000'
FUZZ_REPORT_DIR: fuzz-reports
FUZZ_STRICT_ALLOWLIST: '1'
# --expose-gc turns on the WASM handle-leak probe, which skips without it.
run: node --expose-gc --test "./src/__fuzz__/**/*.test.ts"
continue-on-error: true
Comment thread
jlucaso1 marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

- name: Summarise
id: report
if: always()
run: |
{
node scripts/fuzz/report.ts fuzz-reports --markdown --fail-on-stale
} > fuzz-summary.md 2>&1 && echo "clean=true" >> "$GITHUB_OUTPUT" || echo "clean=false" >> "$GITHUB_OUTPUT"
cat fuzz-summary.md >> "$GITHUB_STEP_SUMMARY"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-reports-${{ github.run_id }}
path: |
fuzz-reports/
fuzz-summary.md
retention-days: 30

- name: Open an issue for the findings
if: always() && steps.report.outputs.clean == 'false'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include crash-only failures in the issue condition

When a generated check or generator crashes without producing a Divergence, fuzz() writes a report whose findings array is empty and only then throws because of its separate crashes list. The summariser therefore sets clean=true, so this condition skips issue creation even though steps.fuzz.outcome is failure; the final gate makes the workflow red, but the uploaded summary misleadingly reports zero findings and no tracking issue is opened. Include the fuzz outcome in this condition and preserve the crash details in the summary or issue.

Useful? React with 👍 / 👎.

uses: actions/github-script@v7
with:
script: |
const fs = require('node:fs')
const summary = fs.readFileSync('fuzz-summary.md', 'utf8')
const seed = process.env.FUZZ_SEED
const title = `Nightly fuzz: findings on seed ${seed}`

// One open issue per topic, updated rather than duplicated: a fuzzer
// that opens a fresh issue every night trains people to close them
// unread.
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'fuzz'
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select only the nightly fuzz tracking issue

When the repository has any unrelated open issue—or even a pull request—carrying the generic fuzz label, this query returns it and the workflow posts the nightly report to existing.data[0] instead of creating or updating its own tracking issue. That can hide actionable nightly failures in an unrelated thread, and the API's first page/order makes the chosen destination unstable as more labeled items are opened. Identify the workflow-owned issue by a stable title or marker and exclude pull requests before commenting.

Useful? React with 👍 / 👎.


const body = [
summary,
'',
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
`Reproduce locally: \`FUZZ_SEED=${seed} FUZZ_MODE=deep npm run fuzz\``,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror nightly flags in the reproduction command

When the nightly fails in the GC-only leak probe, this command runs fuzz without --expose-gc, so that test is skipped; when it fails because an allowlist entry expired, the missing FUZZ_STRICT_ALLOWLIST=1 turns the failure into only a warning. In either case the issue's advertised reproduction can pass, so use the deep script with strict allowlist enforcement, e.g. FUZZ_SEED=... FUZZ_STRICT_ALLOWLIST=1 npm run fuzz:deep.

Useful? React with 👍 / 👎.

'',
'---',
'_Generated by [Claude Code](https://claude.ai/code)_'
].join('\n')

if (existing.data.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.data[0].number,
body
})
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['fuzz']
})
}
env:
FUZZ_SEED: ${{ inputs.seed || github.run_id }}

# The fuzz and summarise steps deliberately do not fail on the spot, so the
# artifacts get uploaded and the issue gets filed first. Without a final gate
# the job would then finish green holding findings, which is the one outcome
# that would make the whole nightly pointless.
- name: Fail the job when the run was not clean
if: always() && (steps.fuzz.outcome != 'success' || steps.report.outputs.clean != 'true')
run: |
echo "fuzz outcome: ${{ steps.fuzz.outcome }}"
echo "report clean: ${{ steps.report.outputs.clean }}"
exit 1
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ so existing integrations can migrate with minimal changes. See
| Key management | JS auth state | Rust `PersistenceManager` |
| Auto-reconnect | Manual `startSock()` loop | Transient drops retried in Rust (fibonacci backoff); terminal ones still yours |

Compatibility is checked rather than assumed: a declaration audit against
upstream's `.d.ts`, a wire-fidelity audit of the send path, ~50 behavioural
compatibility suites, and a
[differential fuzz suite](src/__fuzz__/README.md) that generates its own inputs
from the proto schema and compares the two libraries directly. Differences the
fuzzers find are recorded with a reason and a review date, and known open ones
are listed in `src/__fuzz__/harness/divergence.ts`.

## Documentation

The full API reference and guides live in the
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"lib/**/*",
"!lib/**/*.map",
"!lib/**/__tests__/**",
"!lib/__fuzz__/**",
"!lib/**/*.test.*",
"!lib/**/*.test-e2e.*"
],
Expand All @@ -70,6 +71,10 @@
"prepack": "npm run build && node scripts/check-pack.ts",
"prepare": "npm run build",
"test": "node --test",
"fuzz": "node --test ./src/__fuzz__/**/*.test.ts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new fuzz scripts run .test.ts files through the Node test runner (e.g. node --test ./src/__fuzz__/**/*.test.ts), which requires type stripping. Type stripping is only enabled by default on Node >=23.6; on Node 22.x — which engines declares as the supported minimum (>=22.0.0) — it is gated behind --experimental-strip-types, so npm run fuzz / fuzz:deep / fuzz:record fail with an unknown-file-extension error for anyone running the supported minimum Node. Consider adding --experimental-strip-types to these scripts, or bumping the documented minimum Node, since the CI fuzz/test jobs pin Node 24 and sidestep the mismatch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At package.json, line 74:

<comment>The new `fuzz` scripts run `.test.ts` files through the Node test runner (e.g. `node --test ./src/__fuzz__/**/*.test.ts`), which requires type stripping. Type stripping is only enabled by default on Node >=23.6; on Node 22.x — which `engines` declares as the supported minimum (`>=22.0.0`) — it is gated behind `--experimental-strip-types`, so `npm run fuzz` / `fuzz:deep` / `fuzz:record` fail with an unknown-file-extension error for anyone running the supported minimum Node. Consider adding `--experimental-strip-types` to these scripts, or bumping the documented minimum Node, since the CI fuzz/test jobs pin Node 24 and sidestep the mismatch.</comment>

<file context>
@@ -70,6 +71,10 @@
     "prepack": "npm run build && node scripts/check-pack.ts",
     "prepare": "npm run build",
     "test": "node --test",
+    "fuzz": "node --test ./src/__fuzz__/**/*.test.ts",
+    "fuzz:deep": "FUZZ_MODE=deep node --expose-gc --test ./src/__fuzz__/**/*.test.ts",
+    "fuzz:record": "FUZZ_RECORD=1 node --test ./src/__fuzz__/**/*.test.ts",
</file context>

"fuzz:deep": "FUZZ_MODE=deep node --expose-gc --test ./src/__fuzz__/**/*.test.ts",
"fuzz:record": "FUZZ_RECORD=1 node --test ./src/__fuzz__/**/*.test.ts",
"fuzz:report": "node scripts/fuzz/report.ts",
"test:compat-auditor": "node --test scripts/compatibility/__tests__/audit.test.ts",
"typecheck:compat-auditor": "npm run build --silent && npm run compat:check-waproto --silent && npm run compat:layers --silent && tsc -p scripts/compatibility/tsconfig.json",
"test:e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 ADV_SECRET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= node --expose-gc --test --test-concurrency=1 ./src/__tests__/e2e/*.test-e2e.ts"
Expand Down
6 changes: 3 additions & 3 deletions scripts/compatibility/__tests__/wire-fidelity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ const SEED = 20260808
*/
const KNOWN_DIVERGENT = ['pollResultSnapshotMessageV3']

const contextInfoFields = (
PROTO_MESSAGE_SCHEMAS.find(([path]) => path === 'MessageContextInfo')?.[1] ?? []
).map(field => field[0])
const contextInfoFields = (PROTO_MESSAGE_SCHEMAS.find(([path]) => path === 'MessageContextInfo')?.[1] ?? []).map(
field => field[0]
)

describe('send-path wire fidelity auditor', () => {
it('finds nothing dropped or altered on the current send path', async () => {
Expand Down
4 changes: 3 additions & 1 deletion scripts/compatibility/wire-fidelity-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export const runCli = async (argv: string[]): Promise<number> => {

for (let index = 0; index < argv.length; index++) {
const option = argv[index]!
const [name, inline] = option.includes('=') ? [option.slice(0, option.indexOf('=')), option.slice(option.indexOf('=') + 1)] : [option, undefined]
const [name, inline] = option.includes('=')
? [option.slice(0, option.indexOf('=')), option.slice(option.indexOf('=') + 1)]
: [option, undefined]
const takeValue = () => {
if (inline !== undefined) return inline
const value = argv[index + 1]
Expand Down
26 changes: 18 additions & 8 deletions scripts/compatibility/wire-fidelity-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,9 @@ export type WireSender = (message: Record<string, unknown>) => Promise<Uint8Arra
export const relayedBytes: WireSender = async message => {
const captured: Uint8Array[] = []
const ctx = capturingContext(captured)
await makeMessageMethods(ctx).relayMessage(
'120363000000000000@g.us',
structuredClone(message) as WAProto.IMessage,
{ messageId: '3EB0FIDELITY000000' }
)
await makeMessageMethods(ctx).relayMessage('120363000000000000@g.us', structuredClone(message) as WAProto.IMessage, {
messageId: '3EB0FIDELITY000000'
})
const bytes = captured[0]
if (!bytes) throw new Error('the send path handed no bytes to the bridge')
return bytes
Expand Down Expand Up @@ -289,7 +287,12 @@ export const auditWireFidelity = async (
continue
}
if (valueAt(sent, path) === undefined) {
findings.push({ label: testCase.label, path, status: 'dropped', detail: 'absent from the bytes sent to the bridge' })
findings.push({
label: testCase.label,
path,
status: 'dropped',
detail: 'absent from the bytes sent to the bridge'
})
continue
}
try {
Expand All @@ -299,7 +302,12 @@ export const auditWireFidelity = async (
continue
}
if (!upstreamSees(sentBytes, path)) {
findings.push({ label: testCase.label, path, status: 'divergent', detail: 'upstream protobufjs reads this field at another number' })
findings.push({
label: testCase.label,
path,
status: 'divergent',
detail: 'upstream protobufjs reads this field at another number'
})
continue
}
findings.push({ label: testCase.label, path, status: 'preserved' })
Expand Down Expand Up @@ -328,7 +336,9 @@ export const renderFidelityReport = (report: FidelityReport, details = false): s
for (const finding of report.findings) {
if (finding.status === 'preserved') continue
if (finding.status === 'skipped' && !details) continue
lines.push(`- [${finding.status}] ${finding.label} :: ${finding.path}${finding.detail ? ` (${finding.detail})` : ''}`)
lines.push(
`- [${finding.status}] ${finding.label} :: ${finding.path}${finding.detail ? ` (${finding.detail})` : ''}`
)
}
return lines.join('\n')
}
Loading
Loading