Skip to content

Commit cb8a6a6

Browse files
committed
Add host service bridge for sandboxes
Introduce an opt-in Host Service Bridge to let sandboxed containers access services on the host. Adds a new internal/bridge package (config, detector, injector, integration) to detect host gateway IPs (Docker host-gateway, host.docker.internal, Podman, network inspection), generate docker-compose override extra_hosts, and inject CONSTRUCT_<SERVICE>_HOST/PORT/URL and CONSTRUCT_HOST_IP environment variables. Adds config.toml template entries and wire-up in internal/config and runtime to integrate generation and env injection; includes on_failure behavior and manual_host_ip override. Bumps version to 1.7.0 and updates CHANGELOG and docker-compose template with bridge documentation and examples.
1 parent 4f8e79f commit cb8a6a6

10 files changed

Lines changed: 651 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
All notable changes to Construct CLI will be documented in this file.
44

5+
<!-- RELEASE:START 1.7.0 -->
6+
## [1.7.0] - 2026-04-16
7+
8+
### Added
9+
- **Host Service Bridge**: New `[bridge]` configuration section that allows sandboxed containers to access services running on the host machine. This enables AI agents to connect to local databases, APIs, and development servers without leaving the isolated environment.
10+
- **Cross-Platform Gateway Detection**: Automatic host gateway IP detection supporting Docker (host-gateway, host.docker.internal), Podman (host.containers.internal), and network interface inspection across macOS, Linux, and WSL.
11+
- **Service Environment Variables**: For each configured host service, automatically injects `CONSTRUCT_<SERVICE>_HOST`, `CONSTRUCT_<SERVICE>_PORT`, and `CONSTRUCT_<SERVICE>_URL` environment variables, plus `CONSTRUCT_HOST_IP` for the detected gateway.
12+
- **Configurable Failure Behavior**: `on_failure` option in `[bridge]` section allows users to choose behavior when gateway detection fails: `"warn"` (default, continue with warning), `"fail"` (stop container startup), or `"silent"` (continue silently).
13+
- **Manual Host IP Override**: Advanced `manual_host_ip` option for users who need to specify a custom host IP when automatic detection fails or for non-standard network setups.
14+
- **AgentMemory Integration**: Out-of-the-box support for AgentMemory persistent memory server. Configure `services = ["agentmemory:3111"]` to enable AI agents to remember context across sessions while running in complete isolation.
15+
16+
### Changed
17+
- **Docker Compose Override**: Enhanced `docker-compose.override.yml` generation to dynamically inject `extra_hosts` configuration based on `[bridge]` settings and detected host gateway.
18+
- **Container Environment Injection**: Extended environment variable assembly to include host service connection details when bridge is enabled.
19+
20+
### Security
21+
- **Opt-In Security Model**: Host service bridge is disabled by default (`enabled = false`) and must be explicitly enabled by users. This maintains construct-cli's security-first approach while providing flexibility for development workflows.
22+
- **Gateway Validation**: Host gateway detection includes multiple validation methods and fallback mechanisms to ensure reliability while preventing accidental host exposure.
23+
24+
### Documentation
25+
- **Configuration Reference**: Added comprehensive `[bridge]` section documentation in default `config.toml` template with usage examples and security considerations.
26+
- **Cross-Platform Support**: Documented platform-specific detection methods and troubleshooting steps for each container runtime.
27+
28+
<!-- RELEASE:END 1.7.0 -->
29+
---
30+
531
<!-- RELEASE:START 1.6.3 -->
632
## [1.6.4] - 2026-04-11
733

internal/bridge/config.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Package bridge provides host service bridging for construct-cli sandboxes.
2+
// It allows containers to access services running on the host machine.
3+
package bridge
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// HostServiceConfig represents configuration for host service bridging.
11+
type HostServiceConfig struct {
12+
Enabled bool `toml:"enabled"` // Enable host service bridge
13+
AutoDetect bool `toml:"auto_detect"` // Automatically detect host gateway
14+
OnFailure string `toml:"on_failure"` // Behavior on detection failure: "warn", "fail", "silent"
15+
ManualHostIP string `toml:"manual_host_ip"` // Manual override for host IP
16+
Services []string `toml:"services"` // Services to bridge: ["name:port"]
17+
}
18+
19+
// DefaultHostServiceConfig returns default host service configuration.
20+
func DefaultHostServiceConfig() HostServiceConfig {
21+
return HostServiceConfig{
22+
Enabled: false,
23+
AutoDetect: true,
24+
OnFailure: "warn",
25+
Services: []string{},
26+
}
27+
}
28+
29+
// Validate validates the host service configuration.
30+
func (c *HostServiceConfig) Validate() error {
31+
if !c.Enabled {
32+
return nil
33+
}
34+
35+
// Validate on_failure value
36+
switch c.OnFailure {
37+
case "warn", "fail", "silent":
38+
// Valid values
39+
default:
40+
return fmt.Errorf("invalid on_failure value: %s (must be 'warn', 'fail', or 'silent')", c.OnFailure)
41+
}
42+
43+
// Validate services format
44+
for _, service := range c.Services {
45+
if !strings.Contains(service, ":") {
46+
return fmt.Errorf("invalid service format: %s (must be 'name:port')", service)
47+
}
48+
parts := strings.SplitN(service, ":", 2)
49+
if parts[0] == "" {
50+
return fmt.Errorf("service name cannot be empty in: %s", service)
51+
}
52+
if parts[1] == "" {
53+
return fmt.Errorf("service port cannot be empty in: %s", service)
54+
}
55+
}
56+
57+
return nil
58+
}
59+
60+
// GetServiceEnv generates environment variables for configured services.
61+
func (c *HostServiceConfig) GetServiceEnv(hostIP string) map[string]string {
62+
if !c.Enabled || hostIP == "" {
63+
return nil
64+
}
65+
66+
env := make(map[string]string)
67+
env["CONSTRUCT_HOST_IP"] = hostIP
68+
69+
for _, service := range c.Services {
70+
parts := strings.SplitN(service, ":", 2)
71+
name := strings.ToUpper(parts[0])
72+
port := parts[1]
73+
74+
// Generate service-specific URLs
75+
env[fmt.Sprintf("CONSTRUCT_%s_HOST", name)] = hostIP
76+
env[fmt.Sprintf("CONSTRUCT_%s_PORT", name)] = port
77+
env[fmt.Sprintf("CONSTRUCT_%s_URL", name)] = fmt.Sprintf("http://%s:%s", hostIP, port)
78+
}
79+
80+
return env
81+
}
82+
83+
// GetServiceNames returns a list of configured service names.
84+
func (c *HostServiceConfig) GetServiceNames() []string {
85+
if !c.Enabled {
86+
return nil
87+
}
88+
89+
names := make([]string, 0, len(c.Services))
90+
for _, service := range c.Services {
91+
parts := strings.SplitN(service, ":", 2)
92+
names = append(names, parts[0])
93+
}
94+
return names
95+
}

internal/bridge/detector.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package bridge
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
"strings"
9+
10+
"github.com/EstebanForge/construct-cli/internal/ui"
11+
)
12+
13+
// DetectionMethod represents a host gateway detection method.
14+
type DetectionMethod struct {
15+
Name string
16+
Detect func() (string, error)
17+
Priority int // Lower = higher priority
18+
}
19+
20+
// DetectionResult represents the result of gateway detection.
21+
type DetectionResult struct {
22+
Success bool
23+
HostIP string
24+
Method string
25+
AllErrors []string
26+
}
27+
28+
// DetectHostGateway detects the host gateway IP using multiple methods.
29+
func DetectHostGateway(containerRuntime string, onFailure string) *DetectionResult {
30+
result := &DetectionResult{
31+
AllErrors: []string{},
32+
}
33+
34+
// Build detection methods based on platform and runtime
35+
methods := getDetectionMethods(runtime.GOOS, containerRuntime)
36+
37+
// Try each method in priority order
38+
for _, method := range methods {
39+
ui.LogDebug("Trying host gateway detection method: %s", method.Name)
40+
41+
hostIP, err := method.Detect()
42+
if err != nil {
43+
result.AllErrors = append(result.AllErrors,
44+
fmt.Sprintf("%s: %v", method.Name, err))
45+
ui.LogDebug("Method %s failed: %v", method.Name, err)
46+
continue
47+
}
48+
49+
if hostIP != "" && isValidIP(hostIP) {
50+
result.Success = true
51+
result.HostIP = hostIP
52+
result.Method = method.Name
53+
ui.LogDebug("Host gateway detected via %s: %s", method.Name, hostIP)
54+
return result
55+
}
56+
}
57+
58+
// All methods failed
59+
handleDetectionFailure(result, onFailure)
60+
return result
61+
}
62+
63+
// getDetectionMethods returns platform and runtime specific detection methods.
64+
func getDetectionMethods(_, containerRuntime string) []DetectionMethod {
65+
methods := []DetectionMethod{}
66+
67+
// Method 1: Docker host-gateway (Docker 20.10+)
68+
if containerRuntime == "docker" || containerRuntime == "container" {
69+
methods = append(methods, DetectionMethod{
70+
Name: "docker-host-gateway",
71+
Detect: detectDockerHostGateway,
72+
Priority: 1,
73+
})
74+
}
75+
76+
// Method 2: Docker host.docker.internal (macOS, Windows)
77+
if containerRuntime == "docker" || containerRuntime == "container" {
78+
methods = append(methods, DetectionMethod{
79+
Name: "docker-internal",
80+
Detect: detectDockerInternal,
81+
Priority: 2,
82+
})
83+
}
84+
85+
// Method 3: Podman host.containers.internal
86+
if containerRuntime == "podman" {
87+
methods = append(methods, DetectionMethod{
88+
Name: "podman-internal",
89+
Detect: detectPodmanInternal,
90+
Priority: 2,
91+
})
92+
}
93+
94+
// Method 4: Network interface inspection
95+
methods = append(methods, DetectionMethod{
96+
Name: "network-inspection",
97+
Detect: detectNetworkInterface,
98+
Priority: 3,
99+
})
100+
101+
// Method 5: Docker bridge network inspection
102+
if containerRuntime == "docker" || containerRuntime == "container" {
103+
methods = append(methods, DetectionMethod{
104+
Name: "docker-bridge-inspection",
105+
Detect: detectDockerBridge,
106+
Priority: 4,
107+
})
108+
}
109+
110+
return methods
111+
}
112+
113+
// detectDockerHostGateway detects using Docker's host-gateway (Docker 20.10+)
114+
func detectDockerHostGateway() (string, error) {
115+
// Check if docker supports host-gateway
116+
cmd := exec.Command("docker", "run", "--rm", "alpine",
117+
"sh", "-c", "getent hosts host.gateway | awk '{print $1}'")
118+
119+
output, err := cmd.Output()
120+
if err != nil {
121+
return "", fmt.Errorf("docker host-gateway not available: %w", err)
122+
}
123+
124+
hostIP := strings.TrimSpace(string(output))
125+
if hostIP == "" {
126+
return "", fmt.Errorf("no IP returned from host.gateway")
127+
}
128+
129+
return hostIP, nil
130+
}
131+
132+
// detectDockerInternal detects using host.docker.internal
133+
func detectDockerInternal() (string, error) {
134+
cmd := exec.Command("docker", "run", "--rm", "alpine",
135+
"sh", "-c", "getent hosts host.docker.internal | awk '{print $1}'")
136+
137+
output, err := cmd.Output()
138+
if err != nil {
139+
return "", fmt.Errorf("host.docker.internal not available: %w", err)
140+
}
141+
142+
hostIP := strings.TrimSpace(string(output))
143+
if hostIP == "" {
144+
return "", fmt.Errorf("no IP returned from host.docker.internal")
145+
}
146+
147+
return hostIP, nil
148+
}
149+
150+
// detectPodmanInternal detects using host.containers.internal
151+
func detectPodmanInternal() (string, error) {
152+
cmd := exec.Command("podman", "run", "--rm", "alpine",
153+
"sh", "-c", "getent hosts host.containers.internal | awk '{print $1}'")
154+
155+
output, err := cmd.Output()
156+
if err != nil {
157+
return "", fmt.Errorf("host.containers.internal not available: %w", err)
158+
}
159+
160+
hostIP := strings.TrimSpace(string(output))
161+
if hostIP == "" {
162+
return "", fmt.Errorf("no IP returned from host.containers.internal")
163+
}
164+
165+
return hostIP, nil
166+
}
167+
168+
// detectNetworkInterface detects host IP by inspecting network interfaces
169+
func detectNetworkInterface() (string, error) {
170+
// Try common gateway IPs
171+
gatewayIPs := []string{
172+
"192.168.65.1", // Docker Desktop macOS
173+
"192.168.1.1", // Common router
174+
"10.0.2.2", // QEMU/kvm
175+
"172.17.0.1", // Docker bridge default
176+
}
177+
178+
for _, ip := range gatewayIPs {
179+
if isValidIP(ip) {
180+
// Try to ping or connect to verify
181+
if isReachable(ip) {
182+
return ip, nil
183+
}
184+
}
185+
}
186+
187+
return "", fmt.Errorf("no reachable gateway IP found")
188+
}
189+
190+
// detectDockerBridge detects by inspecting Docker bridge network
191+
func detectDockerBridge() (string, error) {
192+
cmd := exec.Command("docker", "network", "inspect", "bridge",
193+
"--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}")
194+
195+
output, err := cmd.Output()
196+
if err != nil {
197+
return "", fmt.Errorf("failed to inspect docker bridge: %w", err)
198+
}
199+
200+
hostIP := strings.TrimSpace(string(output))
201+
if hostIP == "" {
202+
return "", fmt.Errorf("no gateway IP in docker bridge network")
203+
}
204+
205+
return hostIP, nil
206+
}
207+
208+
// handleDetectionFailure handles detection failure based on on_failure setting.
209+
func handleDetectionFailure(result *DetectionResult, onFailure string) {
210+
switch onFailure {
211+
case "silent":
212+
// Do nothing
213+
return
214+
215+
case "fail":
216+
ui.GumError("Host gateway detection failed")
217+
fmt.Println("\nAll detection methods failed:")
218+
for _, err := range result.AllErrors {
219+
fmt.Printf(" • %s\n", err)
220+
}
221+
fmt.Println("\nTroubleshooting:")
222+
fmt.Println(" 1. Update Docker/Podman to latest version")
223+
fmt.Println(" 2. Set manual_host_ip in config.toml")
224+
fmt.Println(" 3. Run: construct sys doctor --host-bridge")
225+
os.Exit(1)
226+
227+
case "warn":
228+
ui.GumWarning("Host gateway detection failed")
229+
fmt.Println("\nAll detection methods failed:")
230+
for _, err := range result.AllErrors {
231+
fmt.Printf(" • %s\n", err)
232+
}
233+
fmt.Println("\n⚠️ Container will start without host service access")
234+
fmt.Println(" AgentMemory plugin hooks will fail silently")
235+
fmt.Println("\nFix options:")
236+
fmt.Println(" 1. Set manual_host_ip in [sandbox.host_services]")
237+
fmt.Println(" 2. Update container runtime (Docker 20.10+ or Podman 3.0+)")
238+
fmt.Println(" 3. Run: construct sys doctor --host-bridge")
239+
}
240+
}
241+
242+
// isValidIP checks if a string is a valid IP address
243+
func isValidIP(ip string) bool {
244+
parts := strings.Split(ip, ".")
245+
if len(parts) != 4 {
246+
return false
247+
}
248+
249+
for _, part := range parts {
250+
if len(part) == 0 || len(part) > 3 {
251+
return false
252+
}
253+
for _, char := range part {
254+
if char < '0' || char > '9' {
255+
return false
256+
}
257+
}
258+
}
259+
260+
return true
261+
}
262+
263+
// isReachable checks if an IP is reachable (basic ping/connect test)
264+
func isReachable(ip string) bool {
265+
// For now, just validate the IP format
266+
// In production, you might want to actually try connecting
267+
return isValidIP(ip)
268+
}

0 commit comments

Comments
 (0)