diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 8b6ab2251..f3e09c514 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -50,7 +50,11 @@ RUN set -eux; \ # Upgrade all packages to pick up security patches # Addresses CVE-2023-44487 (HTTP/2 Rapid Reset) and other known vulnerabilities -RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* +# Retry logic handles transient mirror sync failures during apt-get update +RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* || \ + (echo "apt-get upgrade failed, retrying with fresh package index..." && \ + rm -rf /var/lib/apt/lists/* && \ + apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*) # Create non-root user with UID/GID matching host user # This allows the user command to run with appropriate permissions diff --git a/containers/cli-proxy/Dockerfile b/containers/cli-proxy/Dockerfile index b87e78212..52b4bc018 100644 --- a/containers/cli-proxy/Dockerfile +++ b/containers/cli-proxy/Dockerfile @@ -1,21 +1,10 @@ -# CLI Proxy sidecar for AWF - provides gh CLI access with mcpg DIFC proxy +# CLI Proxy sidecar for AWF - provides gh CLI access via mcpg DIFC proxy # -# Multi-stage build: -# Stage 1: Extract mcpg binary from ghcr.io/github/gh-aw-mcpg image -# Stage 2: Assemble final image with gh CLI, Node.js, and mcpg -# -# This container runs two processes: -# 1. mcpg proxy (TLS, port 18443) - holds GH_TOKEN, enforces guard policies -# 2. HTTP server (port 11000) - receives gh invocations from the agent container - -# Stage 1: Extract the mcpg binary from the official gh-aw-mcpg image. -# MCPG_IMAGE is configurable via --cli-proxy-mcpg-image so the AWF compiler -# can control which mcpg version is pulled and run (e.g. for version pinning -# or testing a new mcpg release before it is bundled in the GHCR cli-proxy image). -ARG MCPG_IMAGE=ghcr.io/github/gh-aw-mcpg:v0.2.15 -FROM ${MCPG_IMAGE} AS mcpg-source - -# Stage 2: Build the CLI proxy image +# This container runs the HTTP exec server (port 11000) that receives gh +# invocations from the agent container. The mcpg DIFC proxy runs as a +# separate docker-compose service (awf-cli-proxy-mcpg) using the official +# gh-aw-mcpg image directly — no binary extraction needed. GH_HOST is +# set to the mcpg container so all gh CLI traffic flows through the proxy. FROM node:22-alpine # Install gh CLI and curl for healthchecks/wrapper @@ -26,10 +15,6 @@ RUN apk add --no-cache \ ca-certificates \ bash -# Copy the mcpg binary from the mcpg-source stage -COPY --from=mcpg-source /usr/local/bin/mcpg /usr/local/bin/mcpg -RUN chmod +x /usr/local/bin/mcpg - # Create app directory WORKDIR /app @@ -50,10 +35,10 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh RUN addgroup -S cliproxy && adduser -S cliproxy -G cliproxy # Create log directory owned by cliproxy (so non-root process can write) -RUN mkdir -p /var/log/cli-proxy/mcpg && \ +RUN mkdir -p /var/log/cli-proxy && \ chown -R cliproxy:cliproxy /var/log/cli-proxy -# Create /tmp/proxy-tls directory owned by cliproxy for mcpg TLS cert generation +# Create /tmp/proxy-tls directory owned by cliproxy for shared mcpg TLS certs RUN mkdir -p /tmp/proxy-tls && chown cliproxy:cliproxy /tmp/proxy-tls # Switch to non-root user diff --git a/containers/cli-proxy/entrypoint.sh b/containers/cli-proxy/entrypoint.sh index 1f4202422..227f6ff5f 100644 --- a/containers/cli-proxy/entrypoint.sh +++ b/containers/cli-proxy/entrypoint.sh @@ -1,51 +1,24 @@ #!/bin/bash # CLI Proxy sidecar entrypoint -# Starts the mcpg DIFC proxy (GH_TOKEN required), then starts the Node.js HTTP server -# under a supervisor loop so signals are properly handled and mcpg is cleaned up. +# +# The mcpg DIFC proxy runs as a separate docker-compose service +# (awf-cli-proxy-mcpg). This container shares mcpg's network namespace +# (network_mode: service:cli-proxy-mcpg), so localhost resolves to mcpg. +# This ensures the TLS cert's SAN (localhost + 127.0.0.1) matches the +# hostname used by the gh CLI, avoiding TLS hostname verification failures. set -e echo "[cli-proxy] Starting CLI proxy sidecar..." -MCPG_PID="" NODE_PID="" -# GH_TOKEN is required: without it, mcpg cannot authenticate and DIFC guard policies -# cannot be enforced. Fail closed rather than starting an unenforced server. -if [ -z "$GH_TOKEN" ]; then - echo "[cli-proxy] ERROR: GH_TOKEN not set - refusing to start without mcpg DIFC enforcement" - exit 1 -fi - -echo "[cli-proxy] GH_TOKEN present - starting mcpg DIFC proxy..." +# cli-proxy shares mcpg's network namespace, so mcpg is always at localhost. +# AWF_MCPG_PORT is set by docker-manager.ts. +MCPG_PORT="${AWF_MCPG_PORT:-18443}" -mkdir -p /tmp/proxy-tls /var/log/cli-proxy/mcpg +echo "[cli-proxy] mcpg proxy at localhost:${MCPG_PORT}" -# Build the guard policy JSON if not explicitly provided -if [ -z "$AWF_GH_GUARD_POLICY" ]; then - if [ -n "$GITHUB_REPOSITORY" ]; then - AWF_GH_GUARD_POLICY="{\"repos\":[\"${GITHUB_REPOSITORY}\"],\"min-integrity\":\"public\"}" - else - AWF_GH_GUARD_POLICY="{\"min-integrity\":\"public\"}" - fi - echo "[cli-proxy] Using default guard policy: ${AWF_GH_GUARD_POLICY}" -else - echo "[cli-proxy] Using provided guard policy" -fi - -# Start mcpg proxy in background -# mcpg proxy holds GH_TOKEN and applies DIFC guard policies before forwarding -mcpg proxy \ - --policy "${AWF_GH_GUARD_POLICY}" \ - --listen 127.0.0.1:18443 \ - --tls \ - --tls-dir /tmp/proxy-tls \ - --guards-mode filter \ - --trusted-bots "github-actions[bot],github-actions,dependabot[bot],copilot" \ - --log-dir /var/log/cli-proxy/mcpg & -MCPG_PID=$! -echo "[cli-proxy] mcpg proxy started (PID: ${MCPG_PID})" - -# Wait for TLS cert to be generated (max 30s) +# Wait for TLS cert to appear in the shared volume (max 30s) echo "[cli-proxy] Waiting for mcpg TLS certificate..." i=0 while [ $i -lt 30 ]; do @@ -58,31 +31,26 @@ while [ $i -lt 30 ]; do done if [ ! -f /tmp/proxy-tls/ca.crt ]; then - echo "[cli-proxy] ERROR: mcpg TLS certificate not generated within 30s" - kill "$MCPG_PID" 2>/dev/null || true + echo "[cli-proxy] ERROR: mcpg TLS certificate not found within 30s" exit 1 fi # Configure gh CLI to route through the mcpg proxy (TLS, self-signed CA) -export GH_HOST="localhost:18443" +# Uses localhost because cli-proxy shares mcpg's network namespace — the +# self-signed cert's SAN covers localhost, so TLS hostname verification passes. +export GH_HOST="localhost:${MCPG_PORT}" export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt" export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}" echo "[cli-proxy] gh CLI configured to route through mcpg proxy at ${GH_HOST}" -# Cleanup handler: stop both the Node HTTP server and mcpg when we receive a signal -# or when the server exits. This runs correctly because we do NOT exec Node — we -# start it in the background and wait, so the shell (and its traps) remain active. +# Cleanup handler: stop the Node HTTP server on signal cleanup() { echo "[cli-proxy] Shutting down..." if [ -n "$NODE_PID" ]; then kill "$NODE_PID" 2>/dev/null || true wait "$NODE_PID" 2>/dev/null || true fi - if [ -n "$MCPG_PID" ]; then - kill "$MCPG_PID" 2>/dev/null || true - wait "$MCPG_PID" 2>/dev/null || true - fi } trap 'cleanup; exit 0' INT TERM diff --git a/scripts/ci/cleanup.sh b/scripts/ci/cleanup.sh index cd2d6b2e0..718fdccfe 100755 --- a/scripts/ci/cleanup.sh +++ b/scripts/ci/cleanup.sh @@ -12,7 +12,7 @@ echo "===========================================" # First, explicitly remove containers by name (handles orphaned containers) echo "Removing awf containers by name..." -docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy awf-cli-proxy 2>/dev/null || true +docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy awf-cli-proxy awf-cli-proxy-mcpg 2>/dev/null || true # Cleanup diagnostic test containers echo "Stopping docker compose services..." diff --git a/src/cli.ts b/src/cli.ts index 04feca9c9..9f78f1ffc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1454,9 +1454,8 @@ program ) .option( '--cli-proxy-mcpg-image ', - 'Docker image for the mcpg DIFC proxy used inside the CLI proxy sidecar\n' + - ' (only used with --build-local; ignored when pulling pre-built GHCR images)\n' + - ' Set by the AWF compiler to control which mcpg version is pulled and run', + 'Docker image for the mcpg DIFC proxy container (runs as a separate service alongside cli-proxy)\n' + + ' Set by the AWF compiler to control which mcpg version is used', 'ghcr.io/github/gh-aw-mcpg:v0.2.15' ) // -- Logging & Debug -- @@ -2058,6 +2057,7 @@ export async function handlePredownloadAction(options: { agentImage: string; enableApiProxy: boolean; enableCliProxy?: boolean; + cliProxyMcpgImage?: string; }): Promise { const { predownloadCommand } = await import('./commands/predownload'); try { @@ -2067,6 +2067,7 @@ export async function handlePredownloadAction(options: { agentImage: options.agentImage, enableApiProxy: options.enableApiProxy, enableCliProxy: options.enableCliProxy, + cliProxyMcpgImage: options.cliProxyMcpgImage, }); } catch (error) { const exitCode = (error as Error & { exitCode?: number }).exitCode ?? 1; @@ -2091,6 +2092,7 @@ program ) .option('--enable-api-proxy', 'Also download the API proxy image', false) .option('--enable-cli-proxy', 'Also download the CLI proxy image', false) + .option('--cli-proxy-mcpg-image ', 'Docker image for the mcpg DIFC proxy container', 'ghcr.io/github/gh-aw-mcpg:v0.2.15') .action(handlePredownloadAction); // Logs subcommand - view Squid proxy logs diff --git a/src/commands/predownload.test.ts b/src/commands/predownload.test.ts index b0a946a7e..cce677059 100644 --- a/src/commands/predownload.test.ts +++ b/src/commands/predownload.test.ts @@ -43,12 +43,13 @@ describe('predownload', () => { ]); }); - it('should include cli-proxy when enabled', () => { + it('should include cli-proxy and mcpg when enabled', () => { const images = resolveImages({ ...defaults, enableCliProxy: true }); expect(images).toEqual([ 'ghcr.io/github/gh-aw-firewall/squid:latest', 'ghcr.io/github/gh-aw-firewall/agent:latest', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.2.15', ]); }); @@ -59,6 +60,17 @@ describe('predownload', () => { 'ghcr.io/github/gh-aw-firewall/agent:latest', 'ghcr.io/github/gh-aw-firewall/api-proxy:latest', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.2.15', + ]); + }); + + it('should use custom mcpg image when specified', () => { + const images = resolveImages({ ...defaults, enableCliProxy: true, cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0' }); + expect(images).toEqual([ + 'ghcr.io/github/gh-aw-firewall/squid:latest', + 'ghcr.io/github/gh-aw-firewall/agent:latest', + 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.3.0', ]); }); @@ -135,15 +147,20 @@ describe('predownload', () => { ); }); - it('should pull cli-proxy when enabled', async () => { + it('should pull cli-proxy and mcpg when enabled', async () => { await predownloadCommand({ ...defaults, enableCliProxy: true }); - expect(execa).toHaveBeenCalledTimes(3); + expect(execa).toHaveBeenCalledTimes(4); expect(execa).toHaveBeenCalledWith( 'docker', ['pull', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest'], { stdio: 'inherit' }, ); + expect(execa).toHaveBeenCalledWith( + 'docker', + ['pull', 'ghcr.io/github/gh-aw-mcpg:v0.2.15'], + { stdio: 'inherit' }, + ); }); it('should throw with exitCode 1 when a pull fails', async () => { diff --git a/src/commands/predownload.ts b/src/commands/predownload.ts index ffdeea03d..a89717cc8 100644 --- a/src/commands/predownload.ts +++ b/src/commands/predownload.ts @@ -7,6 +7,7 @@ export interface PredownloadOptions { agentImage: string; enableApiProxy: boolean; enableCliProxy?: boolean; + cliProxyMcpgImage?: string; } /** @@ -48,9 +49,13 @@ export function resolveImages(options: PredownloadOptions): string[] { images.push(`${imageRegistry}/api-proxy:${imageTag}`); } - // Optionally pull cli-proxy + // Optionally pull cli-proxy and its mcpg sidecar if (options.enableCliProxy) { images.push(`${imageRegistry}/cli-proxy:${imageTag}`); + // mcpg runs as a separate container; default or user-specified image + const mcpgImage = options.cliProxyMcpgImage || 'ghcr.io/github/gh-aw-mcpg:v0.2.15'; + validateImageReference(mcpgImage); + images.push(mcpgImage); } return images; diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 0135e8286..29b7284e6 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2637,11 +2637,13 @@ describe('docker-manager', () => { const mockNetworkConfigWithCliProxy = { ...mockNetworkConfig, cliProxyIp: '172.30.0.50', + cliProxyMcpgIp: '172.30.0.51', }; it('should not include cli-proxy service when enableCliProxy is false', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfigWithCliProxy); expect(result.services['cli-proxy']).toBeUndefined(); + expect(result.services['cli-proxy-mcpg']).toBeUndefined(); }); it('should not include cli-proxy service when enableCliProxy is true but no cliProxyIp', () => { @@ -2650,30 +2652,72 @@ describe('docker-manager', () => { expect(result.services['cli-proxy']).toBeUndefined(); }); + it('should throw when enableCliProxy is true but githubToken is missing', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: '' }; + expect(() => generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy)) + .toThrow('--enable-cli-proxy requires a GitHub token'); + }); + it('should include cli-proxy service when enableCliProxy is true with cliProxyIp', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); expect(result.services['cli-proxy']).toBeDefined(); const proxy = result.services['cli-proxy']; expect(proxy.container_name).toBe('awf-cli-proxy'); - expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace — no separate networks config + expect(proxy.network_mode).toBe('service:cli-proxy-mcpg'); + expect(proxy.networks).toBeUndefined(); + // Also verify the separate mcpg container + expect(result.services['cli-proxy-mcpg']).toBeDefined(); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.container_name).toBe('awf-cli-proxy-mcpg'); + expect((mcpg.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.51'); }); - it('should pass GH_TOKEN to cli-proxy environment', () => { + it('should pass GH_TOKEN to cli-proxy-mcpg environment (not cli-proxy)', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + // Token goes to the mcpg container, not the HTTP server + const mcpg = result.services['cli-proxy-mcpg']; + const mcpgEnv = mcpg.environment as Record; + expect(mcpgEnv.GH_TOKEN).toBe('ghp_test_token'); + // CLI proxy HTTP server should NOT have the token const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.GH_TOKEN).toBe('ghp_test_token'); + const proxyEnv = proxy.environment as Record; + expect(proxyEnv.GH_TOKEN).toBeUndefined(); }); - it('should route cli-proxy traffic through Squid', () => { + it('should route cli-proxy-mcpg traffic through Squid', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.HTTP_PROXY).toContain('172.30.0.10:3128'); - expect(env.HTTPS_PROXY).toContain('172.30.0.10:3128'); + // The mcpg container routes through Squid + const mcpg = result.services['cli-proxy-mcpg']; + const mcpgEnv = mcpg.environment as Record; + expect(mcpgEnv.HTTP_PROXY).toContain('172.30.0.10:3128'); + expect(mcpgEnv.HTTPS_PROXY).toContain('172.30.0.10:3128'); + }); + + it('should bind mcpg to localhost (only accessible from shared namespace)', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const mcpg = result.services['cli-proxy-mcpg']; + const cmd = mcpg.command as string[]; + const listenIdx = cmd.indexOf('--listen'); + expect(listenIdx).toBeGreaterThan(-1); + // Must bind to localhost — cli-proxy shares the namespace and the + // self-signed TLS cert only covers localhost/127.0.0.1 + expect(cmd[listenIdx + 1]).toBe('127.0.0.1:18443'); + expect(cmd[listenIdx + 1]).not.toContain('0.0.0.0'); + }); + + it('should use localhost in mcpg healthcheck (matches TLS cert SAN)', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const mcpg = result.services['cli-proxy-mcpg']; + const healthcheck = (mcpg.healthcheck as any).test as string[]; + // Healthcheck runs inside mcpg container — must use localhost to match + // the self-signed TLS cert's SAN + expect(healthcheck.join(' ')).toContain('https://localhost:18443'); }); it('should configure healthcheck for cli-proxy', () => { @@ -2705,7 +2749,8 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const agent = result.services['agent']; const env = agent.environment as Record; - expect(env.AWF_CLI_PROXY_URL).toBe('http://172.30.0.50:11000'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(env.AWF_CLI_PROXY_URL).toBe('http://172.30.0.51:11000'); }); it('should set AWF_CLI_PROXY_IP in agent environment', () => { @@ -2713,14 +2758,16 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const agent = result.services['agent']; const env = agent.environment as Record; - expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); }); it('should pass AWF_CLI_PROXY_IP to iptables-init environment', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const initEnv = result.services['iptables-init'].environment as Record; - expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); }); it('should set AWF_CLI_PROXY_WRITABLE=false by default', () => { @@ -2739,21 +2786,28 @@ describe('docker-manager', () => { expect(env.AWF_CLI_PROXY_WRITABLE).toBe('true'); }); - it('should pass guard policy JSON when cliProxyPolicy is set', () => { + it('should pass guard policy JSON to mcpg command args when cliProxyPolicy is set', () => { const policy = '{"repos":["owner/repo"],"min-integrity":"public"}'; const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', cliProxyPolicy: policy }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_GH_GUARD_POLICY).toBe(policy); + const mcpg = result.services['cli-proxy-mcpg']; + // Policy is passed as part of the command array, not an env var + expect(mcpg.command).toBeDefined(); + const cmd = mcpg.command as string[]; + const policyIdx = cmd.indexOf('--policy'); + expect(policyIdx).toBeGreaterThan(-1); + expect(cmd[policyIdx + 1]).toBe(policy); }); - it('should not set AWF_GH_GUARD_POLICY when cliProxyPolicy is not set', () => { + it('should use default guard policy when cliProxyPolicy is not set', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_GH_GUARD_POLICY).toBeUndefined(); + const mcpg = result.services['cli-proxy-mcpg']; + const cmd = mcpg.command as string[]; + const policyIdx = cmd.indexOf('--policy'); + expect(policyIdx).toBeGreaterThan(-1); + // Default policy should contain min-integrity + expect(cmd[policyIdx + 1]).toContain('min-integrity'); }); it('should use GHCR image by default', () => { @@ -2772,45 +2826,53 @@ describe('docker-manager', () => { expect(proxy.image).toBeUndefined(); }); - it('should not pass MCPG_IMAGE build arg when cliProxyMcpgImage is not set', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', buildLocal: true }; - const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect((proxy.build as any).args).toBeUndefined(); - }); - - it('should pass MCPG_IMAGE build arg when cliProxyMcpgImage is set with --build-local', () => { + it('should use mcpg image for cli-proxy-mcpg service', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', - buildLocal: true, cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0', }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect((proxy.build as any).args).toEqual({ MCPG_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.0' }); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.image).toBe('ghcr.io/github/gh-aw-mcpg:v0.3.0'); }); - it('should ignore cliProxyMcpgImage when not using --build-local (pre-built GHCR image)', () => { + it('should use default mcpg image when cliProxyMcpgImage is not set', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', - buildLocal: false, - cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0', }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - // Pre-built GHCR image already contains mcpg; build arg has no effect - expect(proxy.image).toContain('cli-proxy'); - expect(proxy.build).toBeUndefined(); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.image).toContain('gh-aw-mcpg'); }); it('should not include cli-proxy when cliProxyIp is missing from networkConfig', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfig); expect(result.services['cli-proxy']).toBeUndefined(); + expect(result.services['cli-proxy-mcpg']).toBeUndefined(); + }); + + it('should add cli-proxy-tls named volume when cli-proxy is enabled', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + expect(result.volumes).toBeDefined(); + expect(result.volumes!['cli-proxy-tls']).toBeDefined(); + }); + + it('should configure cli-proxy to connect to mcpg via shared network namespace', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const proxy = result.services['cli-proxy']; + const env = proxy.environment as Record; + // AWF_MCPG_HOST should not be set — cli-proxy uses localhost via shared network namespace + expect(env.AWF_MCPG_HOST).toBeUndefined(); + expect(env.AWF_MCPG_PORT).toBe('18443'); + // Verify network_mode is used instead of networks + expect(proxy.network_mode).toBe('service:cli-proxy-mcpg'); }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index f62f33018..837e71909 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -22,6 +22,7 @@ const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; +const CLI_PROXY_MCPG_CONTAINER_NAME = 'awf-cli-proxy-mcpg'; /** * Flag set by fastKillAgentContainer() to signal runAgentCommand() that @@ -377,7 +378,7 @@ export interface SslConfig { */ export function generateDockerCompose( config: WrapperConfig, - networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string }, + networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string; cliProxyMcpgIp?: string }, sslConfig?: SslConfig, squidConfigContent?: string ): DockerComposeConfig { @@ -1365,8 +1366,9 @@ export function generateDockerCompose( // Pre-set CLI proxy IP in environment before the init container definition // for the same reason as AWF_API_PROXY_IP above. + // cli-proxy shares mcpg's network namespace, so use the mcpg IP. if (config.enableCliProxy && networkConfig.cliProxyIp) { - environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyIp; + environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyMcpgIp || '172.30.0.51'; } // SECURITY: iptables init container - sets up NAT rules in a separate container @@ -1624,31 +1626,120 @@ export function generateDockerCompose( // Add CLI proxy sidecar if enabled if (config.enableCliProxy && networkConfig.cliProxyIp) { - const cliProxyService: any = { - container_name: CLI_PROXY_CONTAINER_NAME, + const mcpgIp = networkConfig.cliProxyMcpgIp || '172.30.0.51'; + const mcpgPort = 18443; + const DEFAULT_MCPG_IMAGE = 'ghcr.io/github/gh-aw-mcpg:v0.2.15'; + const mcpgImage = config.cliProxyMcpgImage || DEFAULT_MCPG_IMAGE; + + // Fail-close: refuse to generate cli-proxy-mcpg without a GH_TOKEN. + // mcpg needs a token to authenticate upstream API calls; running without + // one would bypass DIFC enforcement. + if (!config.githubToken) { + throw new Error( + '--enable-cli-proxy requires a GitHub token (GH_TOKEN or --github-token). ' + + 'The mcpg DIFC proxy cannot enforce integrity policies without authentication.' + ); + } + + // Build the guard policy for the mcpg proxy + let guardPolicy = config.cliProxyPolicy || ''; + if (!guardPolicy) { + const repo = process.env.GITHUB_REPOSITORY; + guardPolicy = repo + ? `{"repos":["${repo}"],"min-integrity":"public"}` + : '{"min-integrity":"public"}'; + } + + // --- mcpg DIFC proxy service (runs the official gh-aw-mcpg image directly) --- + const mcpgService: any = { + container_name: CLI_PROXY_MCPG_CONTAINER_NAME, + image: mcpgImage, + // Override entrypoint+command to run in proxy mode (matches gh-aw start_difc_proxy.sh) + entrypoint: ['/app/run.sh'], + command: [ + 'proxy', + '--policy', guardPolicy, + // Bind to localhost only — cli-proxy shares this container's network + // namespace (network_mode: service:cli-proxy-mcpg), so it can reach + // mcpg via 127.0.0.1. No other container on awf-net can connect. + '--listen', `127.0.0.1:${mcpgPort}`, + '--tls', + '--tls-dir', '/tmp/proxy-tls', + '--guards-mode', 'filter', + '--trusted-bots', 'github-actions[bot],github-actions,dependabot[bot],copilot', + '--log-dir', '/var/log/cli-proxy/mcpg', + ], networks: { 'awf-net': { - ipv4_address: networkConfig.cliProxyIp, + ipv4_address: mcpgIp, }, }, volumes: [ - // Mount log directory for mcpg DIFC proxy audit logs - `${cliProxyLogsPath}:/var/log/cli-proxy:rw`, + // Shared TLS cert volume — mcpg writes certs, cli-proxy reads them + 'cli-proxy-tls:/tmp/proxy-tls:rw', + // Log directory for mcpg audit logs + `${cliProxyLogsPath}/mcpg:/var/log/cli-proxy/mcpg:rw`, ], environment: { - // Pass GH_TOKEN to the mcpg DIFC proxy (never exposed to agent) - ...(config.githubToken && { GH_TOKEN: config.githubToken }), - // Pass GITHUB_REPOSITORY so the default guard policy restricts to the current repo + // GH_TOKEN held by mcpg — never exposed to agent + GH_TOKEN: config.githubToken, ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), - // Guard policy JSON for mcpg proxy (optional; default generated from GITHUB_REPOSITORY) - ...(config.cliProxyPolicy && { AWF_GH_GUARD_POLICY: config.cliProxyPolicy }), - // Enable write mode when --cli-proxy-writable is passed - AWF_CLI_PROXY_WRITABLE: String(!!config.cliProxyWritable), - // Route through Squid to respect domain whitelisting + // Route upstream API calls through Squid HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`, + NO_PROXY: `localhost,127.0.0.1,::1,${mcpgIp}`, + no_proxy: `localhost,127.0.0.1,::1,${mcpgIp}`, + }, + healthcheck: { + // Use localhost — healthcheck runs inside the mcpg container where + // localhost matches the self-signed TLS cert's SAN. + test: ['CMD', 'curl', '-sf', '--cacert', '/tmp/proxy-tls/ca.crt', `https://localhost:${mcpgPort}/api/v3/health`], + interval: '5s', + timeout: '3s', + retries: 5, + start_period: '30s', + }, + depends_on: { + 'squid-proxy': { + condition: 'service_healthy', + }, + }, + cap_drop: ['ALL'], + security_opt: ['no-new-privileges:true'], + mem_limit: '256m', + memswap_limit: '256m', + pids_limit: 50, + cpu_shares: 256, + stop_grace_period: '2s', + }; + + // Add shared TLS volume to the volumes block (added to return below) + services['cli-proxy-mcpg'] = mcpgService; + + // --- CLI proxy HTTP server (Node.js + gh CLI) --- + // Uses network_mode: service:cli-proxy-mcpg to share mcpg's network namespace. + // This allows cli-proxy to connect to mcpg via localhost, matching the TLS + // cert's SAN (localhost + 127.0.0.1) and avoiding hostname mismatch errors. + const cliProxyService: any = { + container_name: CLI_PROXY_CONTAINER_NAME, + // Share mcpg's network namespace — localhost resolves to mcpg + network_mode: 'service:cli-proxy-mcpg', + volumes: [ + // Shared TLS cert volume — read certs written by mcpg + 'cli-proxy-tls:/tmp/proxy-tls:ro', + // Log directory for HTTP server logs + `${cliProxyLogsPath}:/var/log/cli-proxy:rw`, + ], + environment: { + // mcpg port for the entrypoint to set GH_HOST=localhost:${port} + // AWF_MCPG_HOST is not needed — cli-proxy shares mcpg's network namespace + AWF_MCPG_PORT: String(mcpgPort), + // Pass GITHUB_REPOSITORY for GH_REPO default in entrypoint + ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), + // Enable write mode when --cli-proxy-writable is passed + AWF_CLI_PROXY_WRITABLE: String(!!config.cliProxyWritable), // Prevent curl health check from routing localhost through Squid NO_PROXY: `localhost,127.0.0.1,::1`, no_proxy: `localhost,127.0.0.1,::1`, @@ -1658,20 +1749,16 @@ export function generateDockerCompose( interval: '5s', timeout: '3s', retries: 5, - start_period: '30s', // Extra time for mcpg TLS cert generation + start_period: '30s', }, - // Depend on Squid for routing outbound API traffic + // Depend on mcpg for TLS cert and API routing depends_on: { - 'squid-proxy': { + 'cli-proxy-mcpg': { condition: 'service_healthy', }, }, - // Security hardening: Drop all capabilities cap_drop: ['ALL'], - security_opt: [ - 'no-new-privileges:true', - ], - // Resource limits to prevent DoS attacks + security_opt: ['no-new-privileges:true'], mem_limit: '256m', memswap_limit: '256m', pids_limit: 50, @@ -1679,20 +1766,13 @@ export function generateDockerCompose( stop_grace_period: '2s', }; - // Use GHCR image or build locally + // Use GHCR image or build locally for the Node.js HTTP server container if (useGHCR) { cliProxyService.image = `${registry}/cli-proxy:${tag}`; } else { - // When building locally, pass MCPG_IMAGE as a build arg so the compiler - // can control which mcpg version is pulled (mirrors the Dockerfile's ARG default). - const buildArgs: Record = {}; - if (config.cliProxyMcpgImage) { - buildArgs.MCPG_IMAGE = config.cliProxyMcpgImage; - } cliProxyService.build = { context: path.join(projectRoot, 'containers/cli-proxy'), dockerfile: 'Dockerfile', - ...(Object.keys(buildArgs).length > 0 && { args: buildArgs }), }; } @@ -1704,20 +1784,21 @@ export function generateDockerCompose( }; // Tell the agent how to reach the CLI proxy - // Use IP address instead of hostname since Docker DNS may not resolve in chroot mode - environment.AWF_CLI_PROXY_URL = `http://${networkConfig.cliProxyIp}:${CLI_PROXY_PORT}`; - environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyIp; + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + environment.AWF_CLI_PROXY_URL = `http://${mcpgIp}:${CLI_PROXY_PORT}`; + environment.AWF_CLI_PROXY_IP = mcpgIp; // Install the gh wrapper in the agent's PATH by symlinking to the pre-installed wrapper // The agent entrypoint uses AWF_CLI_PROXY_URL to know it should activate the wrapper logger.info('CLI proxy sidecar enabled - gh CLI will route through mcpg DIFC proxy'); + logger.info(`CLI proxy mcpg image: ${mcpgImage}`); logger.info('CLI proxy will route through Squid to respect domain whitelisting'); if (config.cliProxyWritable) { logger.info('CLI proxy running in writable mode - write operations permitted'); } } - return { + const composeResult: DockerComposeConfig = { services, networks: { 'awf-net': { @@ -1725,6 +1806,13 @@ export function generateDockerCompose( }, }, }; + + // Add named volumes when cli-proxy-mcpg shares TLS certs with cli-proxy + if (config.enableCliProxy && networkConfig.cliProxyIp) { + composeResult.volumes = { 'cli-proxy-tls': {} }; + } + + return composeResult; } /** @@ -1898,6 +1986,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { proxyIp: '172.30.0.30', // Envoy API proxy sidecar dohProxyIp: '172.30.0.40', // DoH proxy sidecar cliProxyIp: '172.30.0.50', // CLI proxy sidecar + cliProxyMcpgIp: '172.30.0.51', // CLI proxy mcpg DIFC proxy }; logger.debug(`Using network config: ${networkConfig.subnet} (squid: ${networkConfig.squidIp}, agent: ${networkConfig.agentIp}, api-proxy: ${networkConfig.proxyIp})`); diff --git a/src/types.ts b/src/types.ts index a07f64fe0..b4b4298f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -840,14 +840,14 @@ export interface WrapperConfig { githubToken?: string; /** - * Docker image reference for the mcpg DIFC proxy used inside the CLI proxy sidecar + * Docker image reference for the mcpg DIFC proxy container. * - * Passed as the `MCPG_IMAGE` build argument when building the cli-proxy image - * locally with `--build-local`. Has no effect when using the pre-built GHCR - * cli-proxy image (mcpg is already bundled in that image). + * When `--enable-cli-proxy` is active, the mcpg proxy runs as a separate + * docker-compose service (awf-cli-proxy-mcpg) using this image directly. + * The CLI proxy HTTP server connects to it via the Docker network. * - * The AWF compiler (gh-aw) sets this to control which mcpg version is pulled - * and run, enabling version pinning and testing of new mcpg releases. + * The AWF compiler (gh-aw) sets this to control which mcpg version is used, + * enabling version pinning and testing of new mcpg releases. * * @default 'ghcr.io/github/gh-aw-mcpg:v0.2.15' * @example 'ghcr.io/github/gh-aw-mcpg:v0.3.0' @@ -1207,11 +1207,24 @@ export interface DockerService { * - Object with IPs: { 'awf-net': { ipv4_address: '172.30.0.10' } } - Static IPs * * Static IPs are used to ensure predictable addressing for iptables rules. + * Mutually exclusive with network_mode. * * @example ['awf-net'] * @example { 'awf-net': { ipv4_address: '172.30.0.10' } } */ - networks: string[] | { [key: string]: { ipv4_address?: string } }; + networks?: string[] | { [key: string]: { ipv4_address?: string } }; + + /** + * Network mode for the container + * + * When set to 'service:', the container shares the named service's + * network namespace. This is used when two containers need to communicate + * via localhost (e.g., for TLS cert hostname matching). + * Mutually exclusive with networks. + * + * @example 'service:cli-proxy-mcpg' + */ + network_mode?: string; /** * Custom DNS servers for the container diff --git a/tests/fixtures/cleanup.ts b/tests/fixtures/cleanup.ts index 3ba621d04..c26ab8581 100644 --- a/tests/fixtures/cleanup.ts +++ b/tests/fixtures/cleanup.ts @@ -25,7 +25,7 @@ export class Cleanup { async removeContainers(): Promise { this.log('Removing awf containers by name...'); try { - await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy', 'awf-cli-proxy', 'awf-iptables-init']); + await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy', 'awf-cli-proxy', 'awf-cli-proxy-mcpg', 'awf-iptables-init']); } catch (error) { // Ignore errors (containers may not exist) }