fix: resolve host.docker.internal DNS in chroot mode for MCP servers#555
fix: resolve host.docker.internal DNS in chroot mode for MCP servers#555
Conversation
In chroot mode, MCP servers fail to connect to the MCP gateway because host.docker.internal doesn't resolve. Docker adds this entry to the container's /etc/hosts via extra_hosts, but the chroot uses a separate copy of the host's /etc/hosts which lacks this entry. Fix: mount a writable copy of /etc/hosts (instead of the read-only original) when host access is enabled, and inject the host.docker.internal entry from the container's /etc/hosts before entering the chroot. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
💫 TO BE CONTINUED... Smoke Claude was cancelled! Our hero faces unexpected challenges... |
|
📰 DEVELOPING STORY: Smoke Copilot reports was cancelled. Our correspondents are investigating the incident... |
|
Chroot tests failed Smoke Chroot was cancelled - See logs for details. |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 82.16% | 81.87% | 📉 -0.29% |
| Statements | 82.19% | 81.91% | 📉 -0.28% |
| Functions | 81.95% | 81.95% | ➡️ +0.00% |
| Branches | 75.48% | 75.41% | 📉 -0.07% |
📁 Per-file Coverage Changes (1 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/docker-manager.ts |
83.2% → 81.9% (-1.34%) | 82.5% → 81.2% (-1.29%) |
Coverage comparison generated by scripts/ci/compare-coverage.ts
Add tests verifying that: - enableChroot + enableHostAccess mounts a writable chroot-hosts copy - enableChroot without enableHostAccess keeps read-only /etc/hosts mount Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
📰 DEVELOPING STORY: Smoke Copilot reports was cancelled. Our correspondents are investigating the incident... |
|
💫 TO BE CONTINUED... Smoke Claude was cancelled! Our hero faces unexpected challenges... |
|
Chroot tests failed Smoke Chroot was cancelled - See logs for details. |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 82.16% | 82.14% | 📉 -0.02% |
| Statements | 82.19% | 82.17% | 📉 -0.02% |
| Functions | 81.95% | 81.95% | ➡️ +0.00% |
| Branches | 75.48% | 75.54% | 📈 +0.06% |
📁 Per-file Coverage Changes (1 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/docker-manager.ts |
83.2% → 83.1% (-0.12%) | 82.5% → 82.4% (-0.10%) |
Coverage comparison generated by scripts/ci/compare-coverage.ts
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical issue where MCP servers fail to connect in chroot mode because host.docker.internal doesn't resolve. Docker adds this hostname to the container's /etc/hosts via extra_hosts, but chroot mode uses a mount of the host's /etc/hosts which lacks this entry. The fix creates a writable copy of /etc/hosts when both chroot and host access are enabled, allowing the entrypoint to inject the missing DNS entry.
Changes:
- Creates writable copy of
/etc/hostsin chroot+host-access mode instead of read-only mount - Entrypoint injects
host.docker.internalfrom container's/etc/hostsinto chroot's copy - Cleanup removes the injected entry on exit to avoid side effects
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/docker-manager.ts | Conditionally creates writable /etc/hosts copy in workDir when both chroot and host access are enabled; falls back to minimal hosts file if host's /etc/hosts is unreadable |
| containers/agent/entrypoint.sh | Reads host.docker.internal entry from container's /etc/hosts and appends to chroot's copy; cleanup removes the entry using sed on exit |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/docker-manager.ts
Outdated
| ); | ||
|
|
||
| // Mount /etc/hosts for host name resolution inside chroot | ||
| // When host access is enabled, we mount a writable COPY so the entrypoint |
There was a problem hiding this comment.
The comment says "When host access is enabled" but this code is inside the if (config.enableChroot) block (line 440), so it only runs when chroot is enabled. The comment should be more precise: "When BOTH chroot and host access are enabled" to make it clear this is specific to the combination of both features, not just host access alone. This would improve code clarity and prevent future confusion.
| // When host access is enabled, we mount a writable COPY so the entrypoint | |
| // When BOTH chroot and host access are enabled, we mount a writable COPY so the entrypoint |
containers/agent/entrypoint.sh
Outdated
| # Remove the host.docker.internal line we added (runs inside chroot perspective) | ||
| CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/host.docker.internal/d' /etc/hosts 2>/dev/null || true" |
There was a problem hiding this comment.
The sed command uses a pattern /host.docker.internal/d which will delete ANY line containing "host.docker.internal", not just the line that was added. If the chroot-hosts file (copied from the host's /etc/hosts) originally contained a host.docker.internal entry, that original entry would also be deleted by this cleanup.
While unlikely (since the host's /etc/hosts typically wouldn't have this Docker-specific entry), this could cause issues in edge cases. A safer approach would be to:
- Store the exact line that was added in a variable
- Use
grep -vwith the exact line, or use sed with line number matching - Or use
sedwith a more specific pattern that matches the exact IP and entry format
However, given that host.docker.internal is Docker-specific and unlikely to be in the host's /etc/hosts, and the fact that this cleanup uses || true to ignore errors, this is a minor edge case. The current implementation is acceptable but could be more precise.
| # Remove the host.docker.internal line we added (runs inside chroot perspective) | |
| CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/host.docker.internal/d' /etc/hosts 2>/dev/null || true" | |
| # Remove the specific host.docker.internal line we added (runs inside chroot perspective) | |
| CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^127\.0\.0\.1[[:space:]]\+host\.docker\.internal$/d' /etc/hosts 2>/dev/null || true" |
| if (config.enableHostAccess) { | ||
| const chrootHostsPath = path.join(config.workDir, 'chroot-hosts'); | ||
| try { | ||
| fs.copyFileSync('/etc/hosts', chrootHostsPath); | ||
| logger.debug(`Copied /etc/hosts to ${chrootHostsPath} for chroot host access`); | ||
| } catch { | ||
| // Fall back to empty file if host /etc/hosts is not readable | ||
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n'); | ||
| logger.debug('Created minimal chroot-hosts (could not read host /etc/hosts)'); | ||
| } | ||
| agentVolumes.push(`${chrootHostsPath}:/host/etc/hosts`); | ||
| } else { | ||
| agentVolumes.push('/etc/hosts:/host/etc/hosts:ro'); | ||
| } |
There was a problem hiding this comment.
There is no test coverage for the combination of enableChroot and enableHostAccess being both true. The existing tests at line 528-565 test chroot mode, and tests at line 975-1027 test host access, but none test them together.
This new functionality that creates a writable copy of /etc/hosts specifically when both flags are enabled should be covered by a test. The test should verify:
- When both
enableChroot: trueandenableHostAccess: true, a writable mount is created (no:rosuffix) - The mount path includes
chroot-hosts - When only
enableChroot: true(without host access), the mount is read-only with:rosuffix - The file system operation (copyFileSync) is properly mocked to avoid side effects
Consider adding a test case like:
it('should mount writable /etc/hosts copy when both chroot and host access are enabled', () => {
const config = { ...mockConfig, enableChroot: true, enableHostAccess: true };
const result = generateDockerCompose(config, mockNetworkConfig);
const volumes = result.services.agent.volumes as string[];
// Should mount writable chroot-hosts file (not read-only)
const hostsMount = volumes.find(v => v.includes('/host/etc/hosts'));
expect(hostsMount).toBeDefined();
expect(hostsMount).toMatch(/chroot-hosts:\/host\/etc\/hosts$/);
expect(hostsMount).not.toContain(':ro');
});
src/docker-manager.ts
Outdated
| logger.debug(`Copied /etc/hosts to ${chrootHostsPath} for chroot host access`); | ||
| } catch { | ||
| // Fall back to empty file if host /etc/hosts is not readable | ||
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n'); |
There was a problem hiding this comment.
The file permissions on the created chroot-hosts file should be explicitly set to ensure it's writable by the container. While Docker mounts typically preserve permissions, the file will inherit the Node.js process umask which may vary. Since the entrypoint.sh needs to append to this file (line 197 in entrypoint.sh), ensuring it has the correct permissions would make the code more robust.
Consider adding explicit permission setting after file creation:
- After
fs.copyFileSync(): addfs.chmodSync(chrootHostsPath, 0o644); - In the fallback
fs.writeFileSync(): add{ mode: 0o644 }option
This is a defensive programming practice that would prevent potential issues in environments with restrictive umask settings.
| logger.debug(`Copied /etc/hosts to ${chrootHostsPath} for chroot host access`); | |
| } catch { | |
| // Fall back to empty file if host /etc/hosts is not readable | |
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n'); | |
| fs.chmodSync(chrootHostsPath, 0o644); | |
| logger.debug(`Copied /etc/hosts to ${chrootHostsPath} for chroot host access`); | |
| } catch { | |
| // Fall back to empty file if host /etc/hosts is not readable | |
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n', { mode: 0o644 }); |
- Clarify comment: both chroot AND host access required - Add explicit chmod 0o644 on chroot-hosts file for robustness - Use more precise sed pattern for cleanup to avoid removing unrelated entries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded. |
|
💫 TO BE CONTINUED... Smoke Claude failed! Our hero faces unexpected challenges... |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 82.16% | 82.03% | 📉 -0.13% |
| Statements | 82.19% | 82.07% | 📉 -0.12% |
| Functions | 81.95% | 81.95% | ➡️ +0.00% |
| Branches | 75.48% | 75.44% | 📉 -0.04% |
📁 Per-file Coverage Changes (1 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/docker-manager.ts |
83.2% → 82.6% (-0.60%) | 82.5% → 82.0% (-0.56%) |
Coverage comparison generated by scripts/ci/compare-coverage.ts
|
📰 DEVELOPING STORY: Smoke Copilot reports failed. Our correspondents are investigating the incident... |
Security Review: Writable
|
The smoke tests use pre-built GHCR images, so entrypoint.sh changes don't take effect. Move the host.docker.internal injection to docker-manager.ts which resolves the Docker bridge gateway IP and writes it directly to the chroot-hosts file before the container starts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded. |
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
|
💫 TO BE CONTINUED... Smoke Claude failed! Our hero faces unexpected challenges... |
| try { | ||
| fs.copyFileSync('/etc/hosts', chrootHostsPath); | ||
| } catch { | ||
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n'); |
Check failure
Code scanning / CodeQL
Insecure temporary file High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 1 day ago
In general, to fix insecure temporary file issues, you ensure that: (1) the directory in which you create files is safe (owned by the process, with restrictive permissions), (2) you avoid race conditions and symlink attacks (by using atomic operations or checking for symlinks before writing), and (3) you set restrictive file permissions at creation time instead of relying on defaults.
For this specific code, we should keep using config.workDir but harden the creation and handling of chrootHostsPath. A minimal and effective fix, without altering higher‑level behaviour, is:
- Ensure
config.workDirexists with safe permissions before we use it (but we must not change code outside the shown snippet, so we’ll limit ourselves to the local path handling). - When creating
chrootHostsPath, usefs.openSyncwith exclusive creation flags (O_CREAT | O_EXCL) and a restrictive mode (e.g.0o600) to avoid races and ensure the file is created with safe permissions. Then write the content via that descriptor. - If the file already exists, open it with
O_WRONLY | O_TRUNCand avoid following symlinks by verifying that the path is not a symlink usinglstatSyncandS_ISLNKlogic (in Node, by checkingstats.isSymbolicLink()). - When appending the
host.docker.internalentry, again verify that the file is not a symlink and open it safely before appending.
Because we can’t change imports except to add well‑known ones, and we already import fs and path, we’ll implement this logic directly using Node’s fs API. We’ll replace the current try { copyFileSync } catch { writeFileSync } and appendFileSync/chmodSync logic for chrootHostsPath with a small block that safely creates or truncates the file and then writes or appends through file descriptors, while still ending with a final chmodSync(chrootHostsPath, 0o644) to match existing functionality.
| @@ -494,10 +494,41 @@ | ||
| // to the MCP gateway running on the host. | ||
| if (config.enableHostAccess) { | ||
| const chrootHostsPath = path.join(config.workDir, 'chroot-hosts'); | ||
| // Safely create or truncate the chroot hosts file without following symlinks. | ||
| try { | ||
| fs.copyFileSync('/etc/hosts', chrootHostsPath); | ||
| let fd: number | undefined; | ||
| try { | ||
| // Try to create the file exclusively with restrictive permissions. | ||
| fd = fs.openSync(chrootHostsPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); | ||
| } catch (createErr: any) { | ||
| // If the file exists, ensure it is not a symlink, then truncate it. | ||
| if (createErr && createErr.code === 'EEXIST') { | ||
| const stats = fs.lstatSync(chrootHostsPath); | ||
| if (stats.isSymbolicLink()) { | ||
| throw new Error(`Refusing to use symlink for chroot hosts file at ${chrootHostsPath}`); | ||
| } | ||
| fd = fs.openSync(chrootHostsPath, fs.constants.O_WRONLY | fs.constants.O_TRUNC); | ||
| } else { | ||
| throw createErr; | ||
| } | ||
| } | ||
| if (fd === undefined) { | ||
| throw new Error(`Failed to open chroot hosts file at ${chrootHostsPath}`); | ||
| } | ||
| try { | ||
| // Populate the file with a copy of /etc/hosts, or a minimal localhost entry on failure. | ||
| try { | ||
| const etcHostsContent = fs.readFileSync('/etc/hosts'); | ||
| fs.writeFileSync(fd, etcHostsContent); | ||
| } catch { | ||
| fs.writeFileSync(fd, '127.0.0.1 localhost\n'); | ||
| } | ||
| } finally { | ||
| fs.closeSync(fd); | ||
| } | ||
| } catch { | ||
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n'); | ||
| // As a last resort, write a minimal localhost entry directly. | ||
| fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n', { mode: 0o600 }); | ||
| } | ||
| // Resolve host-gateway IP from Docker bridge and append host.docker.internal | ||
| try { | ||
| @@ -507,6 +536,11 @@ | ||
| ]); | ||
| const hostGatewayIp = stdout.trim(); | ||
| if (hostGatewayIp) { | ||
| // Ensure we are not appending to a symlink. | ||
| const stats = fs.lstatSync(chrootHostsPath); | ||
| if (stats.isSymbolicLink()) { | ||
| throw new Error(`Refusing to append to symlink for chroot hosts file at ${chrootHostsPath}`); | ||
| } | ||
| fs.appendFileSync(chrootHostsPath, `${hostGatewayIp}\thost.docker.internal\n`); | ||
| logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`); | ||
| } |
The smoke tests used pre-built GHCR images (--image-tag 0.13.4) which don't include recent fixes to entrypoint.sh and setup-iptables.sh (host.docker.internal iptables bypass, NO_PROXY, etc.). Update the postprocessor to also replace --image-tag/--skip-pull with --build-local so containers are built from source, matching the AWF binary which is already built from source. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
💫 TO BE CONTINUED... Smoke Claude was cancelled! Our hero faces unexpected challenges... |
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
|
Chroot tests failed Smoke Chroot was cancelled - See logs for details. |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 82.16% | 82.03% | 📉 -0.13% |
| Statements | 82.19% | 82.07% | 📉 -0.12% |
| Functions | 81.95% | 81.95% | ➡️ +0.00% |
| Branches | 75.48% | 75.44% | 📉 -0.04% |
📁 Per-file Coverage Changes (1 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/docker-manager.ts |
83.2% → 82.6% (-0.60%) | 82.5% → 82.0% (-0.56%) |
Coverage comparison generated by scripts/ci/compare-coverage.ts
|
Chroot tests failed Smoke Chroot was cancelled - See logs for details. |
|
💫 TO BE CONTINUED... Smoke Claude was cancelled! Our hero faces unexpected challenges... |
Replace --image-tag 0.13.4 --skip-pull with --build-local in all smoke and build-test lock.yml workflows so containers are built from source. This ensures setup-iptables.sh has the host.docker.internal bypass code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
🎬 THE END — Smoke Claude MISSION: ACCOMPLISHED! The hero saves the day! ✨ |
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 82.16% | 82.03% | 📉 -0.13% |
| Statements | 82.19% | 82.07% | 📉 -0.12% |
| Functions | 81.95% | 81.95% | ➡️ +0.00% |
| Branches | 75.48% | 75.44% | 📉 -0.04% |
📁 Per-file Coverage Changes (1 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/docker-manager.ts |
83.2% → 82.6% (-0.60%) | 82.5% → 82.0% (-0.56%) |
Coverage comparison generated by scripts/ci/compare-coverage.ts
|
Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded. |
Security Review: Writable
|
Node.js Build Test Results ✅
Overall: PASS ✅ All Node.js projects installed dependencies and passed their test suites successfully.
|
Build Test: Rust - ❌ FAILEDError: Rust toolchain not available in test environment.
Overall: FAIL IssueThe test environment does not have Rust installed. The SolutionThe workflow needs to install Rust before running tests: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source $HOME/.cargo/env
|
Go Build Test Results ✅
Overall: PASS ✅ All Go projects successfully downloaded dependencies and passed tests.
|
C++ Build Test Results
Overall: PASS ✅ All C++ projects built successfully.
|
Smoke Test Results (Run ID: 21760357211)Last 2 Merged PRs:
Test Results:
Status: PASS 🎉 cc @Mossaka
|
Claude Smoke Test ResultsLast 2 merged PRs:
Test Results:
Status: PASS
|
Build Test: Bun - Results
Overall: FAIL Error DetailsElysia Project:
Hono Project:
Environment Information
Root CauseBoth projects have minimal The test repository appears to be a minimal test case that exposes compatibility issues with Bun running inside Docker containers or restricted environments.
|
❌ Java Build Test FailedError: Maven installation is corrupted and unavailable. Issue DetailsTest Results
Overall: FAILED Root CauseThe Apache Maven installation at Required ActionThe GitHub Actions runner needs a working Maven installation before this workflow can succeed.
|
Chroot Version Comparison Test Results
Overall Result: ❌ Tests failed - version mismatches detected The chroot environment successfully uses host binaries for Go, but Python and Node.js versions differ between host and container environments.
|
Summary
host.docker.internaldoesn't resolve inside the chroothost.docker.internalto the container's/etc/hostsviaextra_hosts, but the chroot uses the host's/etc/hostswhich lacks this entryfetch failed)Changes
src/docker-manager.ts: When chroot + host access are both enabled, mount a writable copy of/etc/hosts(in workDir) instead of the read-only original. This lets the entrypoint inject the missing entry without modifying the actual host file.containers/agent/entrypoint.sh: Before entering the chroot, read thehost.docker.internalentry from the container's/etc/hosts(where Docker placed it) and append it to the writable copy. Cleanup removes the entry on exit.Test plan
🤖 Generated with Claude Code