feat: add esbuild single-file bundle as lightweight distribution#1581
feat: add esbuild single-file bundle as lightweight distribution#1581
Conversation
Add a ~350KB esbuild bundle (release/awf-bundle.js) as an alternative to the ~50MB pkg binary. GitHub hosted runners already have Node.js 22, making the bundled Node.js 18 runtime in pkg redundant. Changes: - Add scripts/build-bundle.mjs: esbuild config that bundles dist/cli.js into a single CJS file with the seccomp profile inlined via `define` - Modify src/docker-manager.ts: support embedded seccomp profile (__AWF_SECCOMP_PROFILE__) and guard --build-local for bundle users - Add esbuild to devDependencies and build:bundle script - Update release.yml to build, smoke-test, and publish the bundle - Update install.sh to prefer the bundle when Node.js >= 20 is available (opt out with AWF_FORCE_BINARY=1) - Add release/ to .gitignore Closes #1577 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
✅ Coverage Check PassedOverall Coverage
📁 Per-file Coverage Changes (1 files)
Coverage comparison generated by |
Mossaka
left a comment
There was a problem hiding this comment.
Security Review: esbuild single-file bundle distribution
I reviewed this PR against the security posture documented in CLAUDE.md and AGENTS.md. Overall the approach is sound, but there are several findings worth addressing.
a. Seccomp Profile Integrity — LOW RISK, one concern
Mechanism: The build script reads containers/agent/seccomp-profile.json at build time and passes it to esbuild via define: { __AWF_SECCOMP_PROFILE__: JSON.stringify(seccompContent) }. At runtime, docker-manager.ts checks typeof __AWF_SECCOMP_PROFILE__ !== 'undefined' and writes it to ${workDir}/seccomp-profile.json.
The good: JSON.stringify(seccompContent) produces a valid JS string literal containing the full JSON. esbuild's define does a textual substitution, so the content will be faithfully embedded. The double-stringify (JSON inside a JS string) is correct — esbuild define expects a JS expression, and JSON.stringify(string) produces a quoted JS string literal. At runtime, __AWF_SECCOMP_PROFILE__ will be a string containing the raw JSON, which is then written to disk. This is functionally equivalent to fs.copyFileSync.
Concern — profile drift: There is no build-time or CI validation that the embedded profile matches the source file. If someone edits seccomp-profile.json but forgets to rebuild the bundle, or if the build pipeline caches a stale dist/ directory, the released bundle could ship an outdated seccomp profile. Recommendation: Add a CI step or test that compares the embedded profile against the source file, e.g., node -e "const b = require('./release/awf-bundle.js'); ..." or a checksum comparison in the release workflow after the bundle is built.
Concern — no JSON parse validation at build time: The build script reads the file as a raw string and inlines it. If the JSON file is malformed (e.g., trailing comma added during editing), this would only be caught at runtime when Docker tries to apply the seccomp profile, not at build time. Recommendation: Add JSON.parse(seccompContent) in build-bundle.mjs to fail fast on malformed JSON.
b. Install Script Security — ACCEPTABLE
SHA256 checksums are preserved: The bundle path still downloads checksums.txt and calls verify_checksum, which performs SHA256 verification. This is the same integrity guarantee as the binary path.
Format validation is weaker but adequate: The binary path validates ELF/Mach-O format via file command. The bundle path validates #!/usr/bin/env node shebang in the first 20 bytes. This is a weaker check (a shebang is trivial to forge), but the SHA256 checksum is the real integrity gate — the format check is defense-in-depth only. The checksums.txt is downloaded over HTTPS from GitHub Releases, which is sufficient.
AWF_FORCE_BINARY=1 escape hatch: Good — allows users to opt out of the bundle path if needed.
c. Supply Chain — LOW RISK
esbuild (v0.25.12) is a well-maintained, widely-used bundler by Evan Wallace (co-founder of Figma). It has 38k+ GitHub stars, is a devDependency only (not shipped to users), and is used in production by Vite, SvelteKit, and many other major projects. The package uses platform-specific optional native binaries which are all published under the @esbuild/ scope by the same author. The package-lock.json includes integrity hashes for all platform binaries.
No known vulnerabilities in esbuild 0.25.x as of my knowledge cutoff.
d. Bundle Content — LOW RISK
esbuild bundles from dist/cli.js (the tsc output), not from source. It bundles all require() dependencies (commander, chalk, execa, js-yaml, etc.) into one file with minify: true. This is standard for Node.js single-file distributions.
Minification does not affect security logic: The security-critical paths (iptables rules, DNS config, domain filtering) are all string constants and function calls that survive minification unchanged. The seccomp profile is a separate JSON string, not affected by minification. Squid config generation (squid-config.ts) produces string output that is passed to Docker as an env var — also unaffected.
No risk of bundling sensitive files: esbuild only follows require() chains from the entry point. It cannot accidentally include files that are not require()d. The containers/ directory contains shell scripts and Dockerfiles which are never require()d — they are referenced only by path in Docker Compose config.
e. --build-local Guard — ADEQUATE
The guard in generateDockerCompose() checks fs.existsSync(path.join(projectRoot, 'containers')). When running from the bundle, __dirname resolves to a temp/release directory where containers/ does not exist, so the check correctly throws. The error message is clear and actionable.
Note: projectRoot is path.join(__dirname, '..'). In the bundle, __dirname is the directory containing the bundle file. Since the bundle is a single .js file (not inside a dist/ subdirectory), projectRoot will be the parent of wherever the bundle lives. This is correct — there is no containers/ directory adjacent to the bundle.
f. Runtime Equivalence — ACCEPTABLE with caveat
All security-critical paths are preserved:
- Seccomp profile: embedded verbatim, written to disk, applied via
security_opt - iptables rules: defined in
setup-iptables.sh(container script, not bundled) - DNS config: generated in
docker-manager.ts, survives bundling - Domain filtering:
squid-config.tsanddomain-patterns.ts, all pure JS - Capability dropping:
entrypoint.sh(container script, not bundled) - No-new-privileges: string constant in
docker-manager.ts
Caveat — __dirname semantics change: In the tsc build, __dirname is <repo>/dist/. In the bundle, __dirname is the directory containing the bundle file. The code uses path.join(__dirname, '..') as projectRoot in multiple places. In tsc mode this resolves to the repo root; in bundle mode it resolves to the parent of the bundle's directory. This is fine for the seccomp profile (handled by __AWF_SECCOMP_PROFILE__) and for --build-local (guarded). But other projectRoot-relative paths may break silently if not guarded. Recommendation: Audit all uses of projectRoot / path.join(__dirname, '..') in docker-manager.ts to ensure they are either (a) guarded by --build-local check, (b) handled by embedded data, or (c) irrelevant to the bundle path.
Summary
| Concern | Severity | Status |
|---|---|---|
| Seccomp profile integrity | Low | Embedded correctly; recommend build-time JSON validation |
| Profile drift risk | Medium | No CI check that bundle matches source; recommend adding one |
| Install script integrity | Low | SHA256 checksums preserved; shebang check is adequate |
| Supply chain (esbuild) | Low | Well-maintained, devDep only |
| Bundle content safety | Low | Only require() chain bundled |
--build-local guard |
Low | Correctly prevents misuse |
__dirname semantics |
Medium | Seccomp and build-local handled; audit other projectRoot uses |
No blocking security issues found. The two medium items (profile drift detection and __dirname audit) are worth addressing but are not exploitable vulnerabilities — they are robustness concerns for a security-critical tool.
There was a problem hiding this comment.
Pull request overview
Adds a lightweight esbuild-based single-file JavaScript bundle distribution for the AWF CLI and updates release/install flows to prefer it when a suitable system Node.js is available.
Changes:
- Introduces
scripts/build-bundle.mjsto producerelease/awf-bundle.jswith an inlined seccomp profile. - Updates
src/docker-manager.tsto support an embedded seccomp profile and to block--build-localwhen running outside a full repo checkout (e.g., bundle install). - Updates release workflow and
install.shto build/publish and preferentially install the bundle on Node.js >= 20.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/docker-manager.ts |
Adds embedded seccomp profile support and guards --build-local for bundled installs. |
scripts/build-bundle.mjs |
New esbuild bundling script that inlines the seccomp profile and emits release/awf-bundle.js. |
package.json |
Adds esbuild devDependency and build:bundle script. |
package-lock.json |
Locks esbuild and its platform-specific optional dependencies. |
install.sh |
Detects Node.js to prefer installing the bundle (with an escape hatch). |
.github/workflows/release.yml |
Builds and smoke-tests the bundle, and publishes it with other release artifacts. |
.gitignore |
Ignores release/ build artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
install.sh
Outdated
| # Check for Node.js >= 20 to decide between bundle and pkg binary | ||
| check_node() { | ||
| if [ "${AWF_FORCE_BINARY:-}" = "1" ]; then | ||
| info "AWF_FORCE_BINARY=1 set, using standalone binary" | ||
| USE_BUNDLE=false | ||
| return | ||
| fi | ||
| if command -v node &> /dev/null; then | ||
| NODE_VERSION=$(node -v | sed 's/^v//') | ||
| NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) | ||
| if [ "$NODE_MAJOR" -ge 20 ]; then | ||
| info "Node.js v${NODE_VERSION} detected (>= 20), using lightweight bundle" | ||
| USE_BUNDLE=true | ||
| return | ||
| fi | ||
| warn "Node.js v${NODE_VERSION} detected but < 20, using standalone binary" | ||
| fi |
There was a problem hiding this comment.
check_node treats any Node.js 20.x as eligible for the bundle, but package.json declares engines.node >=20.12.0. This can select the bundle on Node 20.0–20.11 where runtime behavior may be unsupported. Consider parsing the full semver (major/minor/patch) and requiring >=20.12.0 to match the engine constraint (or update the stated requirement if >=20.0.0 is truly sufficient).
There was a problem hiding this comment.
Fixed in d1d4c65. The version check now requires >=20.12.0 (matching engines.node in package.json) by parsing both major and minor version components.
.github/workflows/release.yml
Outdated
|
|
||
| - name: Create esbuild bundle | ||
| run: | | ||
| npm install esbuild |
There was a problem hiding this comment.
This step runs npm install esbuild even though npm ci already installed devDependencies (including esbuild from package.json). This adds extra time and can introduce version drift from the lockfile. Prefer removing the install and just running the build script (e.g., npm run build:bundle or node scripts/build-bundle.mjs).
| npm install esbuild |
There was a problem hiding this comment.
Fixed in d1d4c65. Removed the redundant npm install esbuild since it's already a devDependency installed by npm ci.
| // Prepend shebang so the file is directly executable | ||
| const outPath = join(projectRoot, 'release', 'awf-bundle.js'); | ||
| const bundleContent = readFileSync(outPath, 'utf-8'); | ||
| if (!bundleContent.startsWith('#!')) { | ||
| writeFileSync(outPath, '#!/usr/bin/env node\n' + bundleContent); | ||
| } |
There was a problem hiding this comment.
The script prepends a shebang and states the output is "directly executable", but it never sets the executable bit on release/awf-bundle.js. Consider chmoding the output (e.g., 0o755) so ./release/awf-bundle.js works immediately after building, matching the comment and typical CLI bundle expectations.
There was a problem hiding this comment.
Fixed in d1d4c65. Added chmodSync(outPath, 0o755) after writing the bundle so ./release/awf-bundle.js is directly executable.
Smoke Test Results✅ GitHub MCP — Last 2 merged PRs: Overall: PASS
|
|
Smoke Test Results —
Overall: PASS
|
Chroot Version Comparison Results
Result: FAILED — Python and Node.js versions differ between host and chroot environments.
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Tighten Node.js version check in install.sh to >=20.12.0 (matches engines.node in package.json) instead of just >=20 - Remove redundant `npm install esbuild` in release workflow since esbuild is already installed via `npm ci` from devDependencies - Add build-time JSON.parse validation for seccomp profile to fail fast on malformed JSON - Set executable bit (chmod 755) on bundle output file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough security review! Addressed the following in d1d4c65: Seccomp profile JSON validation (build-time): Added Also addressed Copilot review feedback in the same commit:
Regarding profile drift and |
Smoke Test Results
Overall: PASS
|
Smoke Test Results
Overall: PASS PR by
|
This comment has been minimized.
This comment has been minimized.
Chroot Version Comparison Results
Overall: ❌ Not all tests passed — Python and Node.js versions differ between host and chroot environments.
|
Smoke Test: GitHub Actions Services Connectivity ✅
All checks passed. (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 7 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * This produces release/awf-bundle.js (~2 MB) which requires only a | ||
| * system Node.js >= 20 to run — no node_modules needed. |
There was a problem hiding this comment.
The header comment says the bundle requires Node.js ">= 20" and is "~2 MB", but the repo’s engines.node is ">=20.12.0" and the PR description cites a much smaller artifact. To avoid misleading users, update this comment to match the actual runtime requirement (>=20.12.0) and either use the current measured size or remove the hard-coded size estimate.
| * This produces release/awf-bundle.js (~2 MB) which requires only a | |
| * system Node.js >= 20 to run — no node_modules needed. | |
| * This produces release/awf-bundle.js which requires only a | |
| * system Node.js >= 20.12.0 to run — no node_modules needed. |
| if command -v node &> /dev/null; then | ||
| NODE_VERSION=$(node -v | sed 's/^v//') | ||
| NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) | ||
| NODE_MINOR=$(echo "$NODE_VERSION" | cut -d. -f2) | ||
| if [ "$NODE_MAJOR" -gt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -ge 12 ]; }; then | ||
| info "Node.js v${NODE_VERSION} detected (>= 20.12.0), using lightweight bundle" | ||
| USE_BUNDLE=true | ||
| return | ||
| fi | ||
| warn "Node.js v${NODE_VERSION} detected but < 20.12.0, using standalone binary" |
There was a problem hiding this comment.
check_node runs under sudo/root (the script exits if not root). On environments where sudo resets PATH (common in CI and when Node is installed via nvm/asdf or GitHub Actions toolcache), command -v node may fail even though Node is available to the invoking user, causing the installer to incorrectly pick the pkg binary. Consider detecting Node using the original user context (e.g., if $SUDO_USER is set, probe via sudo -u "$SUDO_USER" -H command -v node / node -v), or restructure so Node detection happens before privilege escalation.
| ls -lh release/awf-bundle.js | ||
| echo "=== Bundle smoke test ===" | ||
| node release/awf-bundle.js --version | ||
| node release/awf-bundle.js --help | head -5 |
There was a problem hiding this comment.
Piping --help output into head -5 can intermittently fail the step due to EPIPE (the consumer exits early while Node is still writing). To keep the smoke test reliable, redirect help output to a file (or /dev/null) and then head the file, or otherwise avoid truncating via a pipe that closes early.
| node release/awf-bundle.js --help | head -5 | |
| node release/awf-bundle.js --help > awf-help.txt | |
| head -5 awf-help.txt |
lpcox
left a comment
There was a problem hiding this comment.
Security Review — LGTM ✅
Reviewed all 7 changed files with focus on security implications for this firewall project.
What I verified:
Seccomp profile inlining — JSON validated at build time, JSON.stringify() properly escapes content, embedded as compile-time constant (not runtime injection). Fallback chain (embedded → repo path → alt path) is correct.
install.sh — Node.js version comparison logic handles edge cases correctly (20.11.9, 21.0.0, 19.9.0). Checksum verification works for both bundle and binary paths. Shebang validation prevents wrong file downloads. AWF_FORCE_BINARY=1 escape hatch is a good addition.
esbuild config — Platform/target/format are appropriate (node, node20, cjs). __dirname preserved correctly. Shebang written after build to avoid duplication bug.
docker-manager.ts — --build-local guard correctly checks for containers/ directory existence (won't exist in bundle deployment). typeof __AWF_SECCOMP_PROFILE__ !== 'undefined' is the safe way to check for the embedded constant.
Release workflow — Bundle built before checksums, included in sha256sum *, smoke-tested (--version, --help) before publish.
Clean implementation — no security concerns, no bugs found.
|
Smoke test report (run 23878316602)
|
Summary
release/awf-bundle.js) as a lightweight alternative to the ~50MB pkg binaries which bundle the entire Node.js 18 runtime (EOL since April 2025)install.sh) now auto-detects Node.js >= 20 and downloads the bundle instead of the pkg binary (opt out withAWF_FORCE_BINARY=1)define, so the bundle works without thecontainers/directory treeChanges
scripts/build-bundle.mjsdist/cli.jsinto single CJS file with inlined seccomp profilesrc/docker-manager.ts__AWF_SECCOMP_PROFILE__constant; guard--build-localwhen running from bundlepackage.jsonesbuilddevDep andbuild:bundlescript.github/workflows/release.ymlinstall.sh.gitignorerelease/directoryTest plan
npm run buildsucceedsnpm testpasses (1264 tests)npm run lintpasses (warnings only, no errors)node scripts/build-bundle.mjsproduces a working bundlenode release/awf-bundle.js --versionprints correct versionnode release/awf-bundle.js --helpshows CLI helpCloses #1577
🤖 Generated with Claude Code