Skip to content

feat(claude-code): bake happy CLI into default and sandbox images - #131

Open
gatezh wants to merge 4 commits into
masterfrom
feat/claude-code-bake-happy
Open

gatezh wants to merge 4 commits into
masterfrom
feat/claude-code-bake-happy

Conversation

@gatezh

@gatezh gatezh commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

What

Adds the happy.engineering CLI to the claude-code Dockerfile as a third build target, published as ghcr.io/gatezh/devcontainers/claude-code-happy. default and sandbox are unchanged.

Why

happy lets the Happy phone/web app start and drive Claude Code sessions inside a devcontainer. Installing it at container-create time would cost an npm fetch on every rebuild, so it wants to be baked in — but it is expensive enough (+785 MB) that it should not ride along in images that never use it.

Changes

  • claude-code/.devcontainer/Dockerfile — new FROM default AS happy target: ARG HAPPY_VERSION=1.2.3 + npm install -g happy@${HAPPY_VERSION}, with tools/archives removal and npm cache clean --force in the same RUN. Nothing added to base.
  • .github/workflows/build-claude-code.yml — build-happy publish job for the new target and tag, plus amd64/arm64 verify matrix entries.
  • .github/workflows/ci.yml — claude-code-happy added to the PR build matrix. The two existing verify strings are untouched (pure +5 against master).
  • .github/workflows/cleanup-claude-code-ghcr.yml — new package added to the retention matrix, so the heaviest image does not accumulate sha/date tags forever.
  • .github/renovate.json5 — happy joins the grouped, auto-merged devcontainer agent tools rule.
  • claude-code/README.md, README.md — third variant documented; the happy section covers the ~/.happy volume, running the daemon as the compose command:, shutdownAction: none, the pairing blast radius, and why the sandbox deliberately does not get it.

Sizing

Measured on linux/arm64, layer-sum:

Target Image Size Δ
default claude-code 1844 MB unchanged
sandbox claude-code-sandbox 1731 MB unchanged
happy claude-code-happy 2629 MB +785 MB over default

Installing happy the naive way — no cleanup, in base — cost 1638 MB and hit both existing images. Of that, 106 MB was tools/archives (prebuilt difftastic + ripgrep for all six platform/arch combos; the postinstall unpacks only this platform's pair into tools/unpacked, which is the only path the runtime resolves difft/rg from) and 680 MB was the npm cache. Both are removed inside the install RUN.

The remaining ~850 MB is upstream packaging and not reachable from a Dockerfile: happy vendors its own @anthropic-ai/claude-agent-sdk + sandbox-runtime (a second copy of what base already installs, with its Bedrock/Vertex/OpenTelemetry fan-out), and ships the self-hostable happy server — fastify, http-proxy, expo-server-sdk, drizzle-orm, @libsql — in the same npm package as the CLI. That cost is exactly why this is a separate target.

Notes

Verification does not use the happy CLI. happy --version prints its banner, falls through into the interactive auth TUI, fails on non-TTY stdin, and still exits 0 — and so does every other subcommand, including an unknown flag. No invocation of it can assert anything, so the check is npm ls -g --depth=0 happy (exit 1 when absent, prints the exact version, never starts the TUI). --version not exiting is worth reporting upstream.

Renovate soak applies. This branch merges current master, which added minimumReleaseAge: '3 days' to the tools group with an exemption for @anthropic-ai/claude-code only, so happy waits three days after a release. The exact pin also guards the npm name transfer — this package was renamed from happy-coder, which is still published — so it should never be relaxed to a range.

Nothing runs by default, even in the happy variant: CMD is still sleep infinity. Starting the daemon is opt-in per project via compose command:, documented but not wired up here.

Security posture. Pairing is an inbound control channel — whoever holds it can drive Claude Code against the mounted workspace, and happy's bypass modes pass --dangerously-skip-permissions with no per-tool prompt. The README says so, and the sandbox variant deliberately does not ship happy rather than inviting an api.cluster-fluster.com firewall exception.

Out of scope (intentional). Three other Dockerfiles here also install Claude Code — the legacy bun-based image, the standalone ralphex-fe runner, and this repo's own maintainer devcontainer. None is the template projects consume, so none gained happy. Shipping a daemon-loop script plus opt-in compose lines in the template depends on the compose-first split and stays a separate PR.

Verification performed

All three targets built locally for linux/arm64:

  • all three CI verify strings pass against the built images;
  • the claude-code-happy verify string fails against claude-code, confirming it asserts something real;
  • happy is absent from both default and sandbox;
  • after trimming, difft --version and rg --version still work inside the happy image;
  • hadolint: 11 DL3066 infos, identical to master — no new findings; actionlint clean.

Not done locally: linux/amd64 builds and an end-to-end pairing. CI covers the former.

gatezh and others added 2 commits September 9, 2026 09:05
Install the happy.engineering CLI (npm `happy`) in the base stage of the
claude-code Dockerfile so both targets carry it, pinned via HAPPY_VERSION
and bumped by Renovate alongside the other agent tools. CLI only: the
daemon stays opt-in per project (compose `command:` + a ~/.happy volume),
documented in a new README section. CI verifies `happy --version` in both
targets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves the renovate.json5 conflict: master (#126) rewrote the
"devcontainer agent tools" rationale to explain why platformAutomerge
must stay at its default and added a 3-day minimumReleaseAge soak with
a claude-code exemption. Keep master's version verbatim and carry over
only this branch's "four tools" -> "five tools" count.

Effect on happy: it inherits the 3-day soak, so a happy release is not
adopted until it has been public for three days.
@gatezh

gatezh commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Code review

Verified against the published happy@1.2.3 tarball and two real linux/arm64 builds. The three size/CI findings are in the PR body; the rest follow.

Must fix

1. claude-code/README.md build-args table — 4 of 6 rows are stale. The PR adds a row to this table, so per the repo's "bundle adjacent consistency fixes" habit these belong here:

Row Table says Dockerfile has
RTK_VERSION 0.43.0 0.48.0
RALPHEX_VERSION 1.6.0 1.7.0
CLAUDE_CODE_VERSION 2.1.216 2.1.266
AGENT_BROWSER_VERSION 0.32.3 0.37.1

The deeper issue is that the Default column is a hand-maintained mirror of state Renovate rewrites on every bump, so it will drift again next week. Consider replacing the values for Renovate-managed args with a pointer to the Dockerfile and keeping literal defaults only for GIT_DELTA_VERSION, which nothing bumps automatically.

Security

2. The sandbox allowlist advice understates what it opens. The README tells sandbox users to add api.cluster-fluster.com to init-firewall.sh. That is not a generic egress hole — it is an inbound control channel for a third-party relay that can spawn and drive Claude Code sessions in the container. happy's own README notes that its bypass/yolo permission modes pass --dangerously-skip-permissions to the agent with no per-tool approval gate. A stolen or mis-scanned pairing QR is therefore a remote code-execution path into the mounted workspace, and the sandbox target is precisely where that matters most.

Recommend the README say so in one sentence, and that this reinforces finding "base vs default placement" above: the network-restricted image is the wrong place to ship this by default.

3. Supply chain — note why the pin must stay exact. Upstream's README says the package migrated from happy-coder and that the happy name was donated by a previous owner. Transferred npm names are a recognised supply-chain risk class, and happy-coder is still published (last touched 2026-05, v1.1.9) so the two names now diverge. The exact-version pin plus the minimumReleaseAge: '3 days' soak this branch inherits from master are the right mitigations — worth a comment line saying the pin also serves this purpose, so nobody later relaxes it to a range.

4. --ignore-scripts is not an option here (documenting the constraint). The install runs happy's postinstall (scripts/unpack-tools.cjs). Suppressing it would leave tools/unpacked/ empty and break happy's bundled difft/rg. No change requested — worth a comment so a future size-reduction pass does not try it and silently break diffing.

Verified correct — no change needed

Recording these so they are not re-litigated:

  • Package name and version. happy@1.2.3 is the current publish (registry modified 2026-09-05) and its bin provides happy. Not the stale happy-coder.
  • Renovate wiring. The custom-manager regex # renovate: datasource=(?<datasource>...) depName=(?<depName>\S+)\s+ARG [A-Z_]+_VERSION=(?<currentValue>\S+) matches the new annotation — \s+ spans the newline. happy is in matchPackageNames, so it joins the grouped auto-merge and inherits the 3-day soak.
  • The pin rationale comment is factually right. The claim that "the daemon restarts itself whenever the installed version changes" is backed by version mismatch, Daemon version and restarting daemon strings in dist/.
  • happy daemon start-sync is the correct container entrypoint. daemon start is a thin wrapper that spawns ["daemon", "start-sync"] detached, so start-sync is the foreground variant — exactly what a compose command: needs.
  • HAPPY_HOME_DIR, HAPPY_SERVER_URL and the api.cluster-fluster.com default all exist as documented.
  • node ownership and stage placement follow the Claude Code block's documented overlayfs rationale.
  • Lint parity. hadolint (repo .hadolint.yaml) reports 11 DL3066 infos on this branch and 11 on master — zero new findings. actionlint is clean.
  • Layer split. Keeping happy in its own RUN rather than folding it into the Claude Code install is right: it keeps a Renovate bump from invalidating the other tool's layer. Note the cache-clean fix must go inside the happy RUN, since a later layer cannot reclaim earlier bytes.

Not reviewed

The image was not built for linux/amd64, and no end-to-end pairing was exercised. Size and CLI findings were measured on arm64 only; the tools/archives waste is platform-independent by construction.

Addresses the review on #131. happy stays, but it stops being something
every consumer of these images pays for.

Size. Installed the way the first commit did it, happy added 1638 MB to
the shared `base` layer (1844 MB -> 3482 MB, +89%), and both targets
inherited it. Two cleanups inside the install RUN cut that to 785 MB:
drop tools/archives (106 MB of prebuilt difftastic + ripgrep tarballs
for all six platform/arch combos, dead once postinstall has unpacked
this platform's pair into tools/unpacked, which is the only path the
runtime resolves difft and rg from) and clean the npm cache (680 MB,
unreclaimable from a later layer). The remaining ~850 MB is upstream
packaging: happy vendors its own @anthropic-ai/claude-agent-sdk and
sandbox-runtime — a second copy of what `base` already installs, with
its Bedrock/Vertex/OpenTelemetry fan-out — and ships the self-hostable
happy server (fastify, http-proxy, expo-server-sdk, drizzle-orm, libsql)
in the same npm package as the CLI.

Placement. Even at 785 MB it does not belong in `base`. The sandbox
firewall blocks happy's relay, so that target was carrying a tool it
cannot use, and allowlisting api.cluster-fluster.com to fix that would
punch a hole in the one restriction the sandbox exists to enforce — for
a channel that can execute code in the container. happy now builds
`FROM default AS happy` and publishes as claude-code-happy; `default`
and `sandbox` are byte-for-byte what they were before this branch.

Verification. `happy --version` is not a usable probe: it prints the
version banner, falls through into the interactive auth TUI, fails on a
non-TTY stdin, and still exits 0 — as does every other subcommand and
even an unknown flag. The check now asserts the installed package with
`npm ls -g --depth=0 happy`, which exits 1 when absent and never starts
the TUI. Added to build-claude-code.yml too, which the first commit
missed entirely, so the published image was never checked.

Docs. The Build Args table had drifted on four of six rows because it
mirrors values Renovate rewrites; Renovate-managed rows now point at the
Dockerfile instead of carrying a copy that goes stale in days.

Measured on linux/arm64, layer-sum: default 1844 MB (unchanged),
sandbox 1731 MB (unchanged), happy 2629 MB. All three targets build,
all three CI verify strings pass against the built images, the happy
string correctly fails against default, and happy is absent from both
default and sandbox. hadolint findings unchanged from master (11
DL3066 infos, all pre-existing); actionlint clean.
@gatezh

gatezh commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Review items addressed in 6d22bd1

# Finding Resolution
1 +1638 MB to the shared base layer Trimmed to +785 MB (tools/archives + npm cache, same RUN), and moved off base entirely — default and sandbox are now byte-for-byte unchanged
2 sandbox carried a tool its firewall blocks happy is a separate FROM default AS happy target; sandbox ships without it, and the README says the firewall exception is deliberately not recommended
3 CI check could not fail meaningfully Replaced with npm ls -g --depth=0 happy; also added to build-claude-code.yml, which this branch had missed entirely
4 Build Args table stale on 4 of 6 rows Renovate-managed rows now point at the Dockerfile rather than carrying a copy that drifts
5 Sandbox allowlist advice understated the risk README now states the pairing blast radius explicitly
6 Pin rationale did not mention the npm name transfer Noted in the Dockerfile comment, with "never relax to a range"
7 --ignore-scripts would silently break diffing Documented in the Dockerfile comment, so a later size-reduction pass does not try it

One correction to my earlier note on #3: the old happy --version | grep -q 'happy version' did catch happy being absent (grep fails on empty input). What it could not catch is happy being broken — the CLI exits 0 unconditionally, including on an unknown flag, so the probe passes while happy is actively erroring out of its TUI, and it depends on grep -q's EPIPE to terminate. npm ls has neither property.

Also worth doing, not in this PR

  • build-claude-code.yml and ci.yml duplicate the verify strings — now three targets × two files, and this branch already demonstrated the failure mode by updating one and not the other. Worth extracting to a single source before the next tool is added.
  • File the happy --version bug upstream at slopus/happy: --version prints the banner, then continues into the auth TUI instead of exiting, and the CLI returns 0 for unknown flags.

The claude-code verify commands lived in both ci.yml (pull-request
builds) and build-claude-code.yml (post-publish checks) — nine copies of
three unique strings once the happy target arrived. This branch already
demonstrated the failure mode: its first commit updated ci.yml and left
build-claude-code.yml alone, so the published image was never checked.

.github/verify-commands.json is now the single source, keyed by
published image name, and both workflows look commands up in it. The
strings are unchanged: the generated ci.yml build matrix is byte
identical to the previous one for all eight images, and the three
commands removed from build-claude-code.yml's matrix match their new
manifest entries exactly.

Two things fall out of the lookup:

An image added without a manifest entry now fails the job with a GHA
error annotation instead of being built and silently verified against an
empty command. Verified by pointing a call site at a missing key.

build-claude-code.yml no longer interpolates ${{ matrix.verify-command }}
into a run: script. The command reaches the shell as a variable read from
the checked-out file, so nothing from the workflow context is expanded
into shell text.

Each image's paths filter also watches the manifest, so editing a verify
command re-runs it against a real image rather than leaving it
unexercised until the next Dockerfile change. A manifest edit rebuilds
every image, which is rare enough to be worth paying for — and means
this commit exercises the lookup for all six image families, not just
claude-code.

The remaining build-*.yml workflows still pass their own verify-command
to reusable-docker-build.yml. Migrating them is mechanical but touches
publish pipelines a pull request cannot exercise, so it is left for its
own change; the manifest header records that.

actionlint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant