diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index fec1fb57d..e8c49899b 100644 --- a/.github/workflows/smoke-gvisor.lock.yml +++ b/.github/workflows/smoke-gvisor.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f3d595de39123f517727018ac1f6d080ebe9aad40f6450355869c312e363fd86","body_hash":"9beaf0dbfa6a1ae4915469068c67bcc3e147afc27438149e59b782844fd1fa33","compiler_version":"v0.82.7","agent_id":"copilot","engine_versions":{"copilot":"1.0.68"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4a6f3215b1dba42a96e459935beca208dcb67751cdc2addd522d3ee956702fac","body_hash":"351733cecc70a24556659b1cac6d0bb11163956cb9077a1e2cffc121226ca06f","compiler_version":"v0.82.7","agent_id":"copilot","engine_versions":{"copilot":"1.0.68"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"bf7ba42ce6443bf79fa184c9c6a35de202690bfc","version":"v0.82.7"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} # This file was automatically generated by gh-aw (v0.82.7). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -220,16 +220,6 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - sparse-checkout: | - .github - .agents - .antigravity - .claude - .codex - .crush - .gemini - .opencode - .pi sparse-checkout-cone-mode: true fetch-depth: 1 - name: Save agent config folders for base branch restoration @@ -524,35 +514,7 @@ jobs: name: activation path: /tmp/gh-aw - name: Install and configure gVisor runtime - run: | - set -euo pipefail - echo "::group::Install gVisor (runsc)" - ARCH=$(uname -m) - if [ "$ARCH" = "x86_64" ]; then - ARCH="amd64" - elif [ "$ARCH" = "aarch64" ]; then - ARCH="arm64" - fi - URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" - echo "Downloading runsc for ${ARCH}..." - curl -fsSL "${URL}/runsc" -o /tmp/runsc - curl -fsSL "${URL}/containerd-shim-runsc-v1" -o /tmp/containerd-shim-runsc-v1 - sudo install -m 755 /tmp/runsc /usr/local/bin/runsc - sudo install -m 755 /tmp/containerd-shim-runsc-v1 /usr/local/bin/containerd-shim-runsc-v1 - runsc --version - echo "::endgroup::" - - echo "::group::Register runsc as Docker runtime" - sudo runsc install - sudo systemctl reload docker - echo "Docker runtimes:" - docker info --format '{{.Runtimes}}' || docker info | grep -i runtime - echo "::endgroup::" - - echo "::group::Verify gVisor works" - docker run --rm --runtime=runsc hello-world - echo "✅ gVisor runtime verified" - echo "::endgroup::" + run: "set -euo pipefail\necho \"::group::Install gVisor (runsc)\"\nARCH=$(uname -m)\nURL=\"https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}\"\necho \"Downloading runsc for ${ARCH}...\"\ncurl -fsSL \"${URL}/runsc\" -o /tmp/runsc\ncurl -fsSL \"${URL}/containerd-shim-runsc-v1\" -o /tmp/containerd-shim-runsc-v1\nsudo install -m 755 /tmp/runsc /usr/local/bin/runsc\nsudo install -m 755 /tmp/containerd-shim-runsc-v1 /usr/local/bin/containerd-shim-runsc-v1\nrunsc --version\necho \"::endgroup::\"\n\necho \"::group::Register runsc as Docker runtime\"\nsudo runsc install\n# Must use restart (not reload): Docker's SIGHUP reload does NOT call\n# setHostGatewayIP(), so --add-host host.docker.internal:host-gateway\n# breaks for any container started after a reload-only config change.\nsudo systemctl restart docker\necho \"Docker runtimes:\"\ndocker info --format '{{.Runtimes}}' || docker info | grep -i runtime\necho \"::endgroup::\"\n\necho \"::group::Verify gVisor works\"\ndocker run --rm --runtime=runsc hello-world\necho \"✅ gVisor runtime verified\"\necho \"::endgroup::\"\n" - env: GH_TOKEN: ${{ github.token }} id: smoke-data @@ -583,8 +545,36 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.68 env: GH_HOST: github.com - - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.27 --rootless + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null </dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 @@ -956,7 +946,16 @@ jobs: - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi - name: Stop MCP Gateway if: always() continue-on-error: true @@ -1519,11 +1518,13 @@ jobs: path: /tmp/gh-aw-agent - name: Verify gVisor runtime was used run: | - echo "::group::Check agent logs for gVisor runtime" - if grep -q "runtime.*runsc\|gvisor\|runsc" /tmp/gh-aw-agent/*.log 2>/dev/null; then - echo "✅ gVisor runtime confirmed in agent logs" + echo "::group::Check artifacts for gVisor runtime confirmation" + ARTIFACT_ROOT="/tmp/gh-aw-agent" + # Search all text artifacts for gVisor kernel signature or confirmation + if grep -r -l -i 'gVisor' "$ARTIFACT_ROOT" --include '*.log' --include '*.json' --include '*.txt' --include '*.jsonl' 2>/dev/null | head -3; then + echo "✅ gVisor runtime confirmed in agent artifacts" else - echo "⚠️ Could not confirm gVisor runtime in logs (may still have been used)" + echo "⚠️ Could not confirm gVisor in artifacts (agent may not have logged /proc/version)" fi echo "::endgroup::" - name: Token-usage sanity check diff --git a/.github/workflows/smoke-gvisor.md b/.github/workflows/smoke-gvisor.md index a3255fd36..509c5ab9a 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -62,12 +62,13 @@ jobs: path: /tmp/gh-aw-agent - name: Verify gVisor runtime was used run: | - echo "::group::Check agent logs for gVisor runtime" - LOG_DIR="/tmp/gh-aw-agent/sandbox/agent/logs" - if grep -R -qE 'Linux version .*gVisor' "$LOG_DIR" --include '*.log' 2>/dev/null; then - echo "✅ gVisor runtime confirmed in agent logs" + echo "::group::Check artifacts for gVisor runtime confirmation" + ARTIFACT_ROOT="/tmp/gh-aw-agent" + # Search all text artifacts for gVisor kernel signature or confirmation + if grep -r -l -i 'gVisor' "$ARTIFACT_ROOT" --include '*.log' --include '*.json' --include '*.txt' --include '*.jsonl' 2>/dev/null | head -3; then + echo "✅ gVisor runtime confirmed in agent artifacts" else - echo "⚠️ Could not confirm gVisor runtime in logs (expected until AWF runtime plumbing is added)" + echo "⚠️ Could not confirm gVisor in artifacts (agent may not have logged /proc/version)" fi echo "::endgroup::" - name: Token-usage sanity check @@ -78,11 +79,6 @@ steps: set -euo pipefail echo "::group::Install gVisor (runsc)" ARCH=$(uname -m) - if [ "$ARCH" = "x86_64" ]; then - ARCH="amd64" - elif [ "$ARCH" = "aarch64" ]; then - ARCH="arm64" - fi URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" echo "Downloading runsc for ${ARCH}..." curl -fsSL "${URL}/runsc" -o /tmp/runsc @@ -94,7 +90,10 @@ steps: echo "::group::Register runsc as Docker runtime" sudo runsc install - sudo systemctl reload docker + # Must use restart (not reload): Docker's SIGHUP reload does NOT call + # setHostGatewayIP(), so --add-host host.docker.internal:host-gateway + # breaks for any container started after a reload-only config change. + sudo systemctl restart docker echo "Docker runtimes:" docker info --format '{{.Runtimes}}' || docker info | grep -i runtime echo "::endgroup::" @@ -205,9 +204,7 @@ Run `curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://example.com` ## Pre-Fetched PR Data -``` -${{ steps.smoke-data.outputs.SMOKE_PR_DATA }} -``` + ${{ steps.smoke-data.outputs.SMOKE_PR_DATA }} ## Output (MANDATORY) diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index c249a3fa6..ee4437679 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -183,6 +183,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `container.dockerHostPathPrefix` → `--docker-host-path-prefix` - `container.runnerToolCachePath` → *(config-only; checked first for optional read-only runner tool cache mount, before `RUNNER_TOOL_CACHE` and `/home/runner/work/_tool` auto-detection)* - `container.mounts[]` → `-v, --mount` *(repeatable; each array entry maps to one Docker volume mount in `/host_path:/container_path[:ro|rw]` format (both paths must be absolute; host path must exist); in chroot mode, container paths are automatically prefixed with `/host`)* +- `container.containerRuntime` → `--container-runtime` *(user-facing runtime name, e.g. `"gvisor"`; AWF translates to the Docker OCI runtime identifier, e.g. `"runsc"`. Only the agent container uses the custom runtime; infrastructure containers always use `runc`. When set, AWF injects `extra_hosts` entries for compose-internal services to work around DNS issues with non-default runtimes. Requires the runtime to be installed and registered with Docker on the host.)* - `chroot.binariesSourcePath` → *(config-only; overlays a runner-side binaries directory at `/usr/local/bin` inside chroot mode)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 75b14f573..df7b0cdae 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -619,6 +619,11 @@ "pattern": "^/[^:]+:/[^:]+(:(ro|rw))?$" }, "description": "Custom volume mounts for the agent container. Format: \"/host_path:/container_path[:ro|rw]\" (both paths must be absolute). In chroot mode, container paths are automatically prefixed with /host." + }, + "containerRuntime": { + "type": "string", + "enum": ["gvisor"], + "description": "Container runtime for the agent container. Set to \"gvisor\" to run the agent under gVisor's runsc runtime for additional sandboxing. Only the agent container uses the custom runtime; infrastructure containers (squid-proxy, api-proxy) always use the default runc runtime. When set, AWF automatically injects extra_hosts entries for compose-internal services to work around DNS resolution issues with non-default runtimes. Requires gVisor (runsc) to be installed and registered with Docker on the host." } } }, diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts index 905d8adb2..9e73f9f67 100644 --- a/scripts/ci/postprocess-smoke-workflows.ts +++ b/scripts/ci/postprocess-smoke-workflows.ts @@ -74,3 +74,22 @@ for (const workflowPath of codexWorkflowPaths) { console.log(`Skipping ${workflowPath}: no xpia.md changes needed.`); } } + +// ── gVisor workflow: inject --container-runtime gvisor into the AWF command ─── +const gvisorLockPath = path.join(workflowsDir, 'smoke-gvisor.lock.yml'); +try { + let gvisorContent = fs.readFileSync(gvisorLockPath, 'utf-8'); + // Insert --container-runtime gvisor before --config on the awf command line. + // The compiler doesn't support sandbox.agent.containerRuntime yet, so we inject it here. + const awfCmdPattern = /awf --config /g; + const replacedContent = gvisorContent.replace(awfCmdPattern, 'awf --container-runtime gvisor --config '); + if (replacedContent !== gvisorContent) { + fs.writeFileSync(gvisorLockPath, replacedContent); + console.log(` Injected --container-runtime gvisor into AWF command`); + console.log(`Updated ${gvisorLockPath}`); + } else { + console.log(`Skipping ${gvisorLockPath}: no AWF command found to patch.`); + } +} catch { + console.log(`Skipping ${gvisorLockPath}: file not found.`); +} diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 75b14f573..df7b0cdae 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -619,6 +619,11 @@ "pattern": "^/[^:]+:/[^:]+(:(ro|rw))?$" }, "description": "Custom volume mounts for the agent container. Format: \"/host_path:/container_path[:ro|rw]\" (both paths must be absolute). In chroot mode, container paths are automatically prefixed with /host." + }, + "containerRuntime": { + "type": "string", + "enum": ["gvisor"], + "description": "Container runtime for the agent container. Set to \"gvisor\" to run the agent under gVisor's runsc runtime for additional sandboxing. Only the agent container uses the custom runtime; infrastructure containers (squid-proxy, api-proxy) always use the default runc runtime. When set, AWF automatically injects extra_hosts entries for compose-internal services to work around DNS resolution issues with non-default runtimes. Requires gVisor (runsc) to be installed and registered with Docker on the host." } } }, diff --git a/src/cli-options.ts b/src/cli-options.ts index e7593b814..144534e33 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -169,6 +169,12 @@ program ' Useful for split runner/daemon filesystems (e.g. ARC DinD).\n' + ' Example: /host' ) + .option( + '--container-runtime ', + 'Container runtime for the agent container (e.g. "gvisor" for gVisor sandboxing).\n' + + ' AWF translates friendly names to Docker runtime identifiers\n' + + ' (gvisor → runsc). Unknown values are passed through as-is.' + ) // -- Container Configuration -- .option( diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 6de703582..ac42be7b8 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -2,9 +2,22 @@ import { WrapperConfig } from './types'; import { HostAccessConfig, CliProxyHostConfig } from './host-iptables'; import { DEFAULT_DNS_SERVERS } from './dns-resolver'; import { parseDifcProxyHost } from './docker-manager'; -import { CLI_PROXY_IP, DOH_PROXY_IP } from './host-iptables-shared'; -import { TOPOLOGY_NETWORK_NAME } from './topology'; +import { CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP, API_PROXY_IP } from './host-iptables-shared'; +import { TOPOLOGY_NETWORK_NAME, getTopologyContainerIps, patchComposeWithTopologyHosts } from './topology'; +import { runtimeNeedsStaticDns } from './container-runtime'; +/** + * Dependencies injected into the main workflow. + * + * These are implemented by `docker-manager.ts` for the Docker Compose backend. + * A future microVM backend (e.g. Docker sbx) would provide alternative + * implementations that: + * - `writeConfigs` — generate compose for infrastructure only (no agent service) + * - `startContainers` — start Squid + api-proxy via compose, then launch agent + * in a microVM with the sbx proxy chaining through host-side Squid/api-proxy + * - `runAgentCommand` — `sbx run` instead of `docker logs -f` + `docker wait` + * - Cleanup — `sbx rm` + `docker compose down` for infrastructure + */ interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig, cliProxyConfig?: CliProxyHostConfig) => Promise; @@ -119,6 +132,25 @@ export async function runMainWorkflow( ? async () => { logger.info(`Attaching ${config.topologyAttach!.length} trusted container(s) to the internal network...`); await dependencies.connectTopologyContainers!(TOPOLOGY_NETWORK_NAME, config.topologyAttach!); + + // When the agent uses a runtime whose network stack cannot reach + // Docker's embedded DNS (e.g. gVisor), inject /etc/hosts entries for + // topology peers and compose-internal services so hostname resolution + // works without DNS. + if (runtimeNeedsStaticDns(config.containerRuntime)) { + const peerIps = await getTopologyContainerIps(TOPOLOGY_NETWORK_NAME, config.topologyAttach!); + + // Include compose-internal services whose hostnames the agent may + // need to resolve — normally handled by Docker DNS at 127.0.0.11. + peerIps.set('squid-proxy', SQUID_IP); + if (config.enableApiProxy) { + peerIps.set('api-proxy', API_PROXY_IP); + } + + if (peerIps.size > 0) { + patchComposeWithTopologyHosts(config.workDir, peerIps); + } + } } : undefined; diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 13823ddb8..1b4d193fd 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -159,6 +159,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { awfDockerHost: options.dockerHost as string | undefined, upstreamProxy, dockerHostPathPrefix, + containerRuntime: options.containerRuntime as string | undefined, runnerTopology: options.runnerTopology as 'standard' | 'arc-dind' | undefined, sysrootImage: options.sysrootImage as string | undefined, chrootBinariesSourcePath: options.chrootBinariesSourcePath as string | undefined, diff --git a/src/commands/validate-options.test.ts b/src/commands/validate-options.test.ts index 4103733c4..903b902ef 100644 --- a/src/commands/validate-options.test.ts +++ b/src/commands/validate-options.test.ts @@ -96,6 +96,7 @@ const STUB_CONFIG = { awfDockerHost: undefined, upstreamProxy: undefined, dockerHostPathPrefix: undefined, + containerRuntime: undefined, } as unknown as import('../types').WrapperConfig; /** Returns a set of options that pass all validation checks. */ diff --git a/src/config-file.ts b/src/config-file.ts index f32d3dcbb..9c024589a 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -112,6 +112,7 @@ export interface AwfFileConfig { tty?: boolean; dockerHost?: string; dockerHostPathPrefix?: string; + containerRuntime?: string; runnerToolCachePath?: string; mounts?: string[]; }; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 505f08d1c..332de2aa9 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -105,6 +105,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { + describe('resolveDockerRuntime', () => { + it('translates gvisor to runsc', () => { + expect(resolveDockerRuntime('gvisor')).toBe('runsc'); + }); + + it('passes through unknown runtime names unchanged', () => { + expect(resolveDockerRuntime('kata')).toBe('kata'); + expect(resolveDockerRuntime('runsc')).toBe('runsc'); + expect(resolveDockerRuntime('custom-runtime')).toBe('custom-runtime'); + }); + }); + + describe('getRuntimeCapabilities', () => { + it('returns capabilities for known runtimes', () => { + const caps = getRuntimeCapabilities('gvisor'); + expect(caps).toBeDefined(); + expect(caps!.dockerRuntime).toBe('runsc'); + expect(caps!.needsStaticDns).toBe(true); + expect(caps!.executionModel).toBe('compose'); + }); + + it('returns undefined for unknown runtimes', () => { + expect(getRuntimeCapabilities('kata')).toBeUndefined(); + expect(getRuntimeCapabilities('runsc')).toBeUndefined(); + }); + }); + + describe('runtimeNeedsStaticDns', () => { + it('returns true for gvisor', () => { + expect(runtimeNeedsStaticDns('gvisor')).toBe(true); + }); + + it('returns false for unknown runtimes', () => { + expect(runtimeNeedsStaticDns('kata')).toBe(false); + expect(runtimeNeedsStaticDns('runsc')).toBe(false); + }); + + it('returns false for undefined/empty', () => { + expect(runtimeNeedsStaticDns(undefined)).toBe(false); + expect(runtimeNeedsStaticDns('')).toBe(false); + }); + }); + + describe('runtimeUsesComposeAgent', () => { + it('returns true when no runtime is configured', () => { + expect(runtimeUsesComposeAgent(undefined)).toBe(true); + }); + + it('returns true for compose-model runtimes (gvisor)', () => { + expect(runtimeUsesComposeAgent('gvisor')).toBe(true); + }); + + it('returns true for unknown runtimes (assumed compose)', () => { + expect(runtimeUsesComposeAgent('kata')).toBe(true); + expect(runtimeUsesComposeAgent('runsc')).toBe(true); + }); + }); +}); diff --git a/src/container-runtime.ts b/src/container-runtime.ts new file mode 100644 index 000000000..358f1555c --- /dev/null +++ b/src/container-runtime.ts @@ -0,0 +1,150 @@ +/** + * Container runtime resolution and capability detection. + * + * Centralises three concerns: + * + * 1. **Name translation** – user-facing runtime names (e.g. `"gvisor"`) are + * mapped to Docker OCI runtime identifiers (e.g. `"runsc"`). Unknown names + * are passed through unchanged so callers can also use raw Docker names. + * + * 2. **Capability flags** – each runtime can declare behavioural quirks that + * AWF must compensate for. Current flags: + * - `needsStaticDns` – runtime cannot reach Docker's embedded DNS at + * 127.0.0.11, so AWF must inject `/etc/hosts` entries for every service. + * + * 3. **Execution model** – describes how the agent is launched: + * - `compose` – agent is a Docker Compose service alongside Squid/api-proxy + * (default; used by runc and gVisor). The agent container may use a + * non-default OCI runtime but is still orchestrated by `docker compose`. + * - `microvm` – agent runs in a hypervisor-isolated microVM (e.g. Docker + * sbx). Infrastructure services (Squid, api-proxy) stay in Docker Compose + * on the host; only the agent crosses the hypervisor boundary. The sbx + * proxy chains upstream through AWF's host-side Squid for domain filtering, + * and through the api-proxy for token logging/model routing/credential + * injection. + * + * ## Adding a new OCI runtime + * + * Add an entry to {@link RUNTIME_REGISTRY} with `executionModel: 'compose'`, + * the Docker runtime name, and capability flags. All consumers (agent-service, + * cli-workflow, topology) pick up the new runtime automatically via the + * capability query functions. + * + * ## Adding a microVM backend (e.g. Docker sbx) + * + * Add an entry with `executionModel: 'microvm'`. Callers use + * {@link runtimeUsesComposeAgent} to decide whether to include the agent + * service in docker-compose.yml and whether to use `docker logs/wait` or + * the microVM CLI for agent lifecycle management. Infrastructure services + * (Squid, api-proxy) are generated regardless of execution model. + */ + +// ─── Registry ──────────────────────────────────────────────────────────────── + +/** + * How the agent process is launched and managed. + * + * - `compose` – agent is a Docker Compose service (default runc, gVisor, kata, etc.) + * - `microvm` – agent runs in a hypervisor-isolated microVM (Docker sbx, etc.) + */ +export type ExecutionModel = 'compose' | 'microvm'; + +/** Behavioural capabilities / quirks for a container runtime. */ +export interface RuntimeCapabilities { + /** How the agent is launched. Determines whether the agent appears as a + * Docker Compose service or is managed by an external tool. */ + readonly executionModel: ExecutionModel; + + /** + * Docker OCI runtime identifier (set on docker-compose `runtime:` key). + * Only meaningful when `executionModel` is `'compose'`. Undefined for + * microVM backends that don't use Docker's runtime field. + */ + readonly dockerRuntime?: string; + + /** + * When `true`, Docker's embedded DNS (127.0.0.11) is unreachable from inside + * the agent environment. AWF compensates by injecting static `/etc/hosts` + * entries for all compose-internal services and topology peers. + * + * gVisor requires this because its userspace netstack has an isolated sandbox + * loopback that is disconnected from the host netns iptables DNAT rules that + * Docker uses to intercept DNS traffic. + * + * @see https://github.com/google/gvisor/issues/7469 + */ + readonly needsStaticDns: boolean; +} + +/** + * Registry of known runtimes. Each key is the user-facing name accepted in + * `container.containerRuntime`. Add new runtimes here — the rest of AWF + * picks up the capabilities automatically. + */ +const RUNTIME_REGISTRY: Readonly> = { + gvisor: { + executionModel: 'compose', + dockerRuntime: 'runsc', + needsStaticDns: true, + }, + // Future: Docker sbx microVM backend + // sbx: { + // executionModel: 'microvm', + // dockerRuntime: undefined, + // needsStaticDns: false, // sbx manages its own DNS + // }, +}; + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Translates a user-facing container runtime name (e.g. `"gvisor"`) into the + * Docker OCI runtime identifier (e.g. `"runsc"`). Values that don't appear in + * the registry are passed through unchanged (assumed to be raw Docker runtime + * names). Returns `undefined` for microVM backends that don't use Docker's + * runtime field. + */ +export function resolveDockerRuntime(runtime: string): string | undefined { + const entry = RUNTIME_REGISTRY[runtime]; + if (entry) return entry.dockerRuntime; + // Unknown name — pass through as a raw Docker runtime identifier + return runtime; +} + +/** + * Returns the capability flags for a runtime, or `undefined` if the runtime + * is not in the registry (i.e. a raw Docker runtime name was used directly). + */ +export function getRuntimeCapabilities(runtime: string): RuntimeCapabilities | undefined { + return RUNTIME_REGISTRY[runtime]; +} + +/** + * Returns `true` when the configured runtime requires static DNS entries + * (extra_hosts + chroot hosts patching) because Docker's embedded DNS is + * unreachable from inside the agent environment. + * + * Returns `false` for unknown runtimes (passthrough names) — they are assumed + * to work with Docker's standard DNS. + */ +export function runtimeNeedsStaticDns(runtime: string | undefined): boolean { + if (!runtime) return false; + return RUNTIME_REGISTRY[runtime]?.needsStaticDns ?? false; +} + +/** + * Returns `true` when the agent should be included as a Docker Compose service + * (the default for runc, gVisor, and other OCI runtimes). + * + * Returns `false` when the agent is managed by an external tool (e.g. Docker + * sbx microVM) and should NOT appear in docker-compose.yml. Infrastructure + * services (Squid, api-proxy) are always generated regardless. + * + * When no runtime is configured (undefined), defaults to `true` (compose mode). + */ +export function runtimeUsesComposeAgent(runtime: string | undefined): boolean { + if (!runtime) return true; + const entry = RUNTIME_REGISTRY[runtime]; + if (!entry) return true; // unknown runtime → assume compose + return entry.executionModel === 'compose'; +} diff --git a/src/services/agent-environment-options.test.ts b/src/services/agent-environment-options.test.ts index 378d35d95..a01af459e 100644 --- a/src/services/agent-environment-options.test.ts +++ b/src/services/agent-environment-options.test.ts @@ -397,8 +397,8 @@ describe('agent environment: options', () => { const agent = result.services.agent; const squid = result.services['squid-proxy']; - expect(agent.extra_hosts).toEqual(['host.docker.internal:host-gateway']); - expect(squid.extra_hosts).toEqual(['host.docker.internal:host-gateway']); + expect(agent.extra_hosts).toEqual({ 'host.docker.internal': 'host-gateway' }); + expect(squid.extra_hosts).toEqual({ 'host.docker.internal': 'host-gateway' }); }); it('should NOT configure extra_hosts when enableHostAccess is false', () => { diff --git a/src/services/agent-service-build.test.ts b/src/services/agent-service-build.test.ts index 471a0714e..aea10aed2 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -531,4 +531,94 @@ describe('agent service', () => { } }); }); + + describe('container runtime', () => { + it('should not set runtime when containerRuntime is not specified', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent as any; + + expect(agent.runtime).toBeUndefined(); + }); + + it('should set runtime when containerRuntime is specified', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'gvisor', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + const agent = result.services.agent as any; + + expect(agent.runtime).toBe('runsc'); + }); + + it('should only set runtime on agent, not on squid', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'gvisor', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + + expect((result.services.agent as any).runtime).toBe('runsc'); + expect((result.services['squid-proxy'] as any).runtime).toBeUndefined(); + }); + + it('should inject compose-internal service hosts when containerRuntime is set', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'gvisor', + enableApiProxy: true, + }; + const networkWithProxy = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }; + const result = generateDockerCompose(configWithRuntime, networkWithProxy); + const agent = result.services.agent as any; + + expect(agent.extra_hosts).toEqual({ + 'squid-proxy': mockNetworkConfig.squidIp, + 'api-proxy': '172.30.0.30', + }); + }); + + it('should not inject api-proxy host when proxyIp is absent', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'gvisor', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + const agent = result.services.agent as any; + + expect(agent.extra_hosts['squid-proxy']).toBe(mockNetworkConfig.squidIp); + expect(agent.extra_hosts['api-proxy']).toBeUndefined(); + }); + + it('should pass through unknown runtime names unchanged', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'kata', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + const agent = result.services.agent as any; + + expect(agent.runtime).toBe('kata'); + }); + + it('should not inject compose-internal hosts for runtimes that do not need static DNS', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'kata', + enableApiProxy: true, + }; + const networkWithProxy = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }; + const result = generateDockerCompose(configWithRuntime, networkWithProxy); + const agent = result.services.agent as any; + + expect(agent.extra_hosts?.['squid-proxy']).toBeUndefined(); + expect(agent.extra_hosts?.['api-proxy']).toBeUndefined(); + }); + }); }); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 41aa59c3a..1c7c2ae02 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -6,6 +6,7 @@ import { } from '../constants'; import { ACT_PRESET_BASE_IMAGE, getSafeHostUid, getSafeHostGid } from '../host-identity'; import { buildRuntimeImageRef } from '../image-tag'; +import { resolveDockerRuntime, runtimeNeedsStaticDns } from '../container-runtime'; import { logger } from '../logger'; import { WrapperConfig } from '../types'; import { NetworkConfig, ImageBuildConfig } from './squid-service'; @@ -154,12 +155,37 @@ export function buildAgentService(params: AgentServiceParams): any { // Enable host.docker.internal for agent when --enable-host-access is set if (config.enableHostAccess) { - agentService.extra_hosts = ['host.docker.internal:host-gateway']; + agentService.extra_hosts = { 'host.docker.internal': 'host-gateway' }; environment.AWF_ENABLE_HOST_ACCESS = '1'; } Object.assign(agentService, resolveAgentImageConfig(config, imageConfig)); + // Set container runtime if specified (e.g., "gvisor" → Docker runtime "runsc") + if (config.containerRuntime) { + const dockerRuntime = resolveDockerRuntime(config.containerRuntime); + if (dockerRuntime) { + agentService.runtime = dockerRuntime; + logger.debug(`Set agent container runtime to: ${dockerRuntime} (from config: ${config.containerRuntime})`); + } + + // Some runtimes (e.g. gVisor) have a userspace netstack with an isolated + // sandbox loopback that cannot reach Docker's embedded DNS at 127.0.0.11. + // For these runtimes, inject static /etc/hosts entries for all + // compose-internal services the agent may need to reach by hostname. + // See: https://github.com/google/gvisor/issues/7469 + if (runtimeNeedsStaticDns(config.containerRuntime)) { + if (!agentService.extra_hosts) { + agentService.extra_hosts = {}; + } + agentService.extra_hosts['squid-proxy'] = networkConfig.squidIp; + if (networkConfig.proxyIp) { + agentService.extra_hosts['api-proxy'] = networkConfig.proxyIp; + } + logger.debug('Injected compose-internal service hosts for static DNS compatibility'); + } + } + return agentService; } diff --git a/src/services/cli-proxy-service.test.ts b/src/services/cli-proxy-service.test.ts index 9dd01ef36..158c3cbc5 100644 --- a/src/services/cli-proxy-service.test.ts +++ b/src/services/cli-proxy-service.test.ts @@ -61,7 +61,7 @@ describe('CLI proxy sidecar (external DIFC proxy)', () => { const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; - expect(proxy.extra_hosts).toContain('host.docker.internal:host-gateway'); + expect(proxy.extra_hosts).toEqual({ 'host.docker.internal': 'host-gateway' }); }); it('should mount CA cert as read-only volume when difcProxyCaCert is set', () => { diff --git a/src/services/cli-proxy-service.ts b/src/services/cli-proxy-service.ts index ee3458594..ae950b48c 100644 --- a/src/services/cli-proxy-service.ts +++ b/src/services/cli-proxy-service.ts @@ -53,7 +53,7 @@ export function buildCliProxyService(params: CliProxyServiceParams): CliProxyBui }, }, // Enable host.docker.internal resolution for connecting to host DIFC proxy - extra_hosts: ['host.docker.internal:host-gateway'], + extra_hosts: { 'host.docker.internal': 'host-gateway' }, volumes: applyHostPathPrefixToVolumes( [ // Log directory for HTTP server logs diff --git a/src/services/squid-service.ts b/src/services/squid-service.ts index e1ccb7b8d..87248c2cc 100644 --- a/src/services/squid-service.ts +++ b/src/services/squid-service.ts @@ -160,7 +160,7 @@ export function buildSquidService(params: SquidServiceParams): any { // Security note: When combined with allowing host.docker.internal domain, // containers can access any port on the host if (config.enableHostAccess) { - squidService.extra_hosts = ['host.docker.internal:host-gateway']; + squidService.extra_hosts = { 'host.docker.internal': 'host-gateway' }; logger.debug('Host access enabled: host.docker.internal will resolve to host gateway'); } diff --git a/src/topology.test.ts b/src/topology.test.ts index 3283b9c99..bb1561eb0 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -1,8 +1,14 @@ import execa from 'execa'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as yaml from 'js-yaml'; import { TOPOLOGY_NETWORK_NAME, assertTopologySupported, connectTopologyContainers, + getTopologyContainerIps, + patchComposeWithTopologyHosts, } from './topology'; jest.mock('execa'); @@ -147,4 +153,113 @@ describe('topology', () => { ).rejects.toThrow(/exited with code 1/); }); }); + + describe('getTopologyContainerIps', () => { + it('returns IP addresses for connected containers', async () => { + mockedExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '172.30.0.40' } as any); + mockedExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '172.30.0.41' } as any); + const log = { info: jest.fn(), warn: jest.fn() }; + + const ips = await getTopologyContainerIps('awf-net', ['mcp-gateway', 'difc-proxy'], log); + + expect(ips.size).toBe(2); + expect(ips.get('mcp-gateway')).toBe('172.30.0.40'); + expect(ips.get('difc-proxy')).toBe('172.30.0.41'); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('172.30.0.40')); + }); + + it('warns and skips containers with no IP', async () => { + mockedExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '' } as any); + const log = { info: jest.fn(), warn: jest.fn() }; + + const ips = await getTopologyContainerIps('awf-net', ['missing'], log); + + expect(ips.size).toBe(0); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Could not determine IP')); + }); + + it('warns and skips containers when docker inspect fails', async () => { + mockedExeca.mockRejectedValueOnce(new Error('docker not found')); + const log = { info: jest.fn(), warn: jest.fn() }; + + const ips = await getTopologyContainerIps('awf-net', ['broken'], log); + + expect(ips.size).toBe(0); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to inspect')); + }); + }); + + describe('patchComposeWithTopologyHosts', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'topology-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('adds extra_hosts entries and patches chroot hosts file', () => { + // Create a hosts file that the compose volume references + const hostsDir = path.join(tmpDir, 'chroot-abc'); + fs.mkdirSync(hostsDir); + const hostsFile = path.join(hostsDir, 'hosts'); + fs.writeFileSync(hostsFile, '127.0.0.1 localhost\n'); + + const compose = { + services: { + agent: { + container_name: 'awf-agent', + networks: { 'awf-net': { ipv4_address: '172.30.0.20' } }, + volumes: [`${hostsFile}:/host/etc/hosts:ro`], + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); + const log = { info: jest.fn(), warn: jest.fn() }; + + const peerIps = new Map([['mcp-gateway', '172.30.0.40']]); + patchComposeWithTopologyHosts(tmpDir, peerIps, log); + + const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')) as any; + expect(patched.services.agent.extra_hosts).toEqual({ 'mcp-gateway': '172.30.0.40' }); + // Verify hosts file was also patched + const hostsContent = fs.readFileSync(hostsFile, 'utf8'); + expect(hostsContent).toContain('172.30.0.40\tmcp-gateway'); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Appended')); + }); + + it('appends to existing extra_hosts without overwriting', () => { + const compose = { + services: { + agent: { + container_name: 'awf-agent', + extra_hosts: { 'host.docker.internal': 'host-gateway' }, + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); + const log = { info: jest.fn(), warn: jest.fn() }; + + const peerIps = new Map([['mcp-gateway', '172.30.0.40']]); + patchComposeWithTopologyHosts(tmpDir, peerIps, log); + + const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')) as any; + expect(patched.services.agent.extra_hosts).toEqual({ + 'host.docker.internal': 'host-gateway', + 'mcp-gateway': '172.30.0.40', + }); + }); + + it('warns and returns when agent service is not found', () => { + const compose = { services: { squid: {} } }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); + const log = { info: jest.fn(), warn: jest.fn() }; + + patchComposeWithTopologyHosts(tmpDir, new Map([['x', '1.2.3.4']]), log); + + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find agent service')); + }); + }); }); diff --git a/src/topology.ts b/src/topology.ts index de3281a8e..4f85dfc0c 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -1,4 +1,7 @@ +import * as fs from 'fs'; +import * as path from 'path'; import execa from 'execa'; +import * as yaml from 'js-yaml'; import { getLocalDockerEnv } from './docker-host'; import { logger } from './logger'; @@ -131,3 +134,102 @@ export async function connectTopologyContainers( } } } + +const IPV4_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +/** + * Inspects the IP addresses of topology-attached containers on the specified + * Docker network. Used when the agent runs under an alternative container + * runtime (e.g., gVisor's runsc) whose userspace netstack cannot resolve + * container names via Docker's embedded DNS at 127.0.0.11. + * + * @see https://github.com/google/gvisor/issues/7469 — gVisor DNS incompatibility + */ +export async function getTopologyContainerIps( + networkName: string, + containerNames: string[], + log: TopologyLogger = logger, +): Promise> { + const ips = new Map(); + for (const name of containerNames) { + try { + const result = await execa( + 'docker', + [ + 'inspect', + '--format', + `{{(index .NetworkSettings.Networks "${networkName}").IPAddress}}`, + name, + ], + { env: getLocalDockerEnv(), reject: false }, + ); + const ip = (result.stdout || '').trim(); + if (ip && IPV4_REGEX.test(ip)) { + ips.set(name, ip); + log.info(`Topology peer "${name}" has IP ${ip} on ${networkName}`); + } else { + log.warn(`Could not determine IP for topology peer "${name}" on ${networkName}`); + } + } catch (err) { + log.warn(`Failed to inspect topology peer "${name}": ${err}`); + } + } + return ips; +} + +/** + * Patches docker-compose.yml to add /etc/hosts entries for topology-attached + * containers in the agent service. This bypasses Docker's embedded DNS + * (127.0.0.11) for runtimes whose network stack cannot reach it (e.g. gVisor). + * + * Must be called AFTER topology containers are connected to the network + * (so their IPs are known) and BEFORE the full `docker compose up` that + * starts the agent container (so it picks up the extra_hosts entries). + */ +export function patchComposeWithTopologyHosts( + workDir: string, + peerIps: Map, + log: TopologyLogger = logger, +): void { + const composePath = path.join(workDir, 'docker-compose.yml'); + const content = fs.readFileSync(composePath, 'utf8'); + const compose = yaml.load(content) as any; + + const agentService = compose?.services?.agent; + if (!agentService) { + log.warn('Could not find agent service in docker-compose.yml; skipping topology DNS patch'); + return; + } + + if (!agentService.extra_hosts) { + agentService.extra_hosts = {}; + } + for (const [name, ip] of peerIps) { + agentService.extra_hosts[name] = ip; + } + + fs.writeFileSync(composePath, yaml.dump(compose, { lineWidth: -1 }), { mode: 0o600 }); + log.info(`Patched docker-compose.yml with ${peerIps.size} topology peer host(s) for static DNS compatibility`); + + // Also patch the chroot hosts file. The agent runs chrooted to /host, so it + // reads /host/etc/hosts — a pre-generated file mounted read-only from the host. + // Docker's extra_hosts only populates the container's /etc/hosts (outside chroot). + // Find the source path of the /host/etc/hosts bind mount and append entries there. + const volumes: string[] = agentService.volumes || []; + const hostsMount = volumes.find((v: string) => v.includes(':/host/etc/hosts')); + if (hostsMount) { + const hostPath = hostsMount.split(':')[0]; + try { + let hostsEntries = ''; + for (const [name, ip] of peerIps) { + hostsEntries += `${ip}\t${name}\n`; + } + fs.appendFileSync(hostPath, hostsEntries); + log.info(`Appended ${peerIps.size} topology peer(s) to chroot hosts file: ${hostPath}`); + } catch (err) { + log.warn(`Could not patch chroot hosts file at ${hostPath}: ${err}`); + } + } else { + log.info('No /host/etc/hosts mount found (sysroot-stage mode); topology peers rely on extra_hosts + container DNS'); + } +} diff --git a/src/types/container-image-options.ts b/src/types/container-image-options.ts index c7ed3dc05..c99349dd9 100644 --- a/src/types/container-image-options.ts +++ b/src/types/container-image-options.ts @@ -100,4 +100,18 @@ export interface ContainerImageOptions { * @example '/host' */ dockerHostPathPrefix?: string; + + /** + * OCI container runtime for the agent container + * + * Sets the Docker Compose `runtime:` field on the agent service. + * The named runtime must be registered in the Docker daemon + * (e.g., `runsc` for gVisor, `kata` for Kata Containers). + * + * Only the agent container uses this runtime; infrastructure containers + * (squid, api-proxy, cli-proxy) always run under the default runtime. + * + * @example 'runsc' + */ + containerRuntime?: string; } diff --git a/src/types/docker.ts b/src/types/docker.ts index bc6c28fcd..f0c35fe38 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -158,12 +158,12 @@ interface DockerService { /** * Extra hosts to add to /etc/hosts in the container * - * Array of host:ip mappings. Used to enable host.docker.internal - * on Linux where it's not available by default. + * Mapping of hostname to IP/alias. Docker Compose V2 mapping format. + * Used to enable host.docker.internal on Linux and for gVisor DNS compat. * - * @example ['host.docker.internal:host-gateway'] + * @example { 'host.docker.internal': 'host-gateway' } */ - extra_hosts?: string[]; + extra_hosts?: Record; /** * Volume mount specifications @@ -378,6 +378,18 @@ interface DockerService { * @example ['/tmp/awf-123:rw,noexec,nosuid,size=1m'] */ tmpfs?: string[]; + + /** + * OCI container runtime to use for this service + * + * When set, Docker Compose passes `--runtime=` to the container engine. + * Requires the named runtime to be registered in the Docker daemon configuration + * (e.g., via `runsc install` for gVisor or kata-runtime for Kata Containers). + * + * @example 'runsc' // gVisor + * @example 'kata' // Kata Containers + */ + runtime?: string; } /**