fix(devcontainer): unbreak ttyd and restore npm-global bin on PATH - #4674
Merged
Conversation
The firewall aborted before reaching its DROP policies, so the container ran with no egress restriction at all while looking perfectly healthy. Three independent faults, all inherited from the upstream reference script (anthropics/claude-code .devcontainer/init-firewall.sh), which still has all three today: 1. Chain policies were never reset after the flush. `iptables -F` clears rules but not policy, and postStartCommand re-runs this script on every container start/attach. On a re-run the policies from the bottom of this file were still DROP while every ACCEPT rule had just been flushed away, so the GitHub-meta fetch failed, `set -e` aborted, and the container was left with DROP policies and zero rules - no network at all until recreated. 2. `ipset add` without `-exist` made a duplicate address fatal. DNS answers legitimately repeat an address (auth.openai.com returns 104.18.41.241 twice), which aborted the run. 3. A domain that no longer resolves was fatal. statsig.anthropic.com - a local addition, not in the upstream list - lost its A record upstream, so the script died there on every single run. Non-required domains now warn and are skipped, and are named in a summary line; only api.anthropic.com and registry.npmjs.org remain fatal. Faults 2 and 3 abort before the DROP policies are set, which is exactly why this stayed invisible: the container kept working, just unfirewalled, and nobody reads postStartCommand output on a healthy-looking box. Verified in the running dev container: three consecutive runs, exit 0 each time, both built-in verifications passing (example.com blocked, api.github.com reachable), 81 allowlist entries, and `claude -p` reaching the API from inside the container. No issue id: infrastructure fix found while diagnosing a live container outage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the init-firewall.sh repair, both surfaced only once that
script started applying again.
ttyd (7681) was unreachable from everywhere. The rule intended to restrict it
to Tailscale cannot work under Docker Desktop: published-port traffic arrives
through a userland proxy that REWRITES the source, so every client - loopback,
LAN, Tailscale alike - appears as one fixed address (measured: 172.66.144.201).
The source match never fires and the following DROP takes everyone. Native
Linux Docker DNATs and preserves the source, where the filter does work, so it
is kept behind TTYD_SOURCE_FILTER=1 rather than deleted.
That moves enforcement to the only layer that sees the real client: the host
port binding. ttyd is an UNAUTHENTICATED writable web terminal and appPort
published it on 0.0.0.0, i.e. a shell offered to the whole LAN. It now binds
${localEnv:TTYD_BIND_IP:127.0.0.1} - loopback by default, set TTYD_BIND_IP to
a Tailscale address to reach it remotely. sshd on 2222 stays on all interfaces:
key-only, no passwords, no root login.
Separately, `claude` and `pnpm` were "command not found" in any LOGIN shell -
every ssh session, and anything tmux or ttyd starts that way. Debian's
/etc/profile hardcodes PATH for non-root login shells, clobbering the ENV PATH
from this Dockerfile and dropping /usr/local/share/npm-global/bin. A
profile.d snippet re-adds it after /etc/profile has run, plus an explicit zsh
hook since zsh does not read profile.d itself.
Verified in the running container: firewall applies twice consecutively at
exit 0 with both built-in checks passing, ttyd answers 200 over loopback,
Tailscale and LAN (pre-binding), api.anthropic.com reachable, example.com
still blocked, and `claude -p` works from a bash login shell. The Dockerfile
RUN block was executed against a stripped PATH to confirm it appends correctly.
Stacked on fix-devcontainer-firewall; enqueue only after that lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ttraenkler
added a commit
to ttraenkler/js2
that referenced
this pull request
Aug 21, 2026
Another lane pushed loopdive#4673/loopdive#4674 (init-firewall re-run safety, ttyd PATH) onto this branch. Everything else it carried was already in via the earlier upstream/main catch-up, so the effective diff is .devcontainer/ only — no source, no gate surface. Merged, never rebased. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #4673 — do not enqueue until that lands. Both issues surfaced only once
init-firewall.shstarted applying again.ttyd was unreachable from everywhere
The rule meant to restrict ttyd to Tailscale cannot work under Docker Desktop. Published-port traffic arrives through a userland proxy that rewrites the source, so every client — loopback, LAN and Tailscale alike — appears as one fixed address (measured:
172.66.144.201). The source match never fires and the followingDROPtakes everyone.Native Linux Docker DNATs and preserves the client address, where the filter does work, so it is kept behind
TTYD_SOURCE_FILTER=1rather than deleted.…which moves enforcement to the port binding
ttyd is an unauthenticated writable web terminal, and
appPortpublished it on0.0.0.0— a shell offered to the entire LAN. The host binding is the only layer that sees the real client, so that is where the restriction now lives:Loopback by default; set
TTYD_BIND_IPto a Tailscale address for remote access. sshd on 2222 stays on all interfaces — key-only, no passwords, no root login.claude/pnpmwere "command not found" in login shellsDebian's
/etc/profilehardcodesPATHfor non-root login shells, clobbering theENV PATHset in the Dockerfile and dropping/usr/local/share/npm-global/bin. That hits every login shell — every ssh session, and anything tmux or ttyd starts that way — while a plaindocker execkept working, which is what made it look like Claude had been uninstalled. Aprofile.dsnippet re-adds the directory after/etc/profilehas run, plus an explicit zsh hook since zsh doesn't readprofile.ditself.Verification
In the running container:
200over loopback, Tailscale and LAN (measured pre-binding).api.anthropic.comreachable,example.comstill blocked.claude -pworks from abash -llogin shell.RUNblock was executed against a deliberately strippedPATHto confirm it appends correctly.🤖 Generated with Claude Code