From e6d058b5c0f77873e05bcebf850e9d878237f215 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 05:45:07 -0700 Subject: [PATCH 01/16] chore: recompile smoke-gvisor workflow on branch for testing Recompiles the gVisor smoke workflow so it can be triggered via the test-gvisor label on this PR branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index fec1fb57d..2ceeeabb5 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":"e60dfb75055b7ed39e77d66b69345e651b66c42582da38e91b755488eec2acfa","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 # @@ -1520,10 +1520,11 @@ jobs: - 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 + 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" else - echo "⚠️ Could not confirm gVisor runtime in logs (may still have been used)" + echo "⚠️ Could not confirm gVisor runtime in logs (expected until AWF runtime plumbing is added)" fi echo "::endgroup::" - name: Token-usage sanity check From 79304828b6782dafed292d5a28f22b1d66375493 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 05:49:04 -0700 Subject: [PATCH 02/16] fix: use raw uname -m for gVisor download URL The gVisor release URL expects the raw architecture string from uname -m (e.g., x86_64) not the remapped form (amd64). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 7 +------ .github/workflows/smoke-gvisor.md | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index 2ceeeabb5..2b3953b73 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":"e60dfb75055b7ed39e77d66b69345e651b66c42582da38e91b755488eec2acfa","body_hash":"351733cecc70a24556659b1cac6d0bb11163956cb9077a1e2cffc121226ca06f","compiler_version":"v0.82.7","agent_id":"copilot","engine_versions":{"copilot":"1.0.68"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e2ee3551b102f033f12b691db760c7e8d73d8e8eaba8b5cad8a8fae1c3b81285","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 # @@ -528,11 +528,6 @@ jobs: 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 diff --git a/.github/workflows/smoke-gvisor.md b/.github/workflows/smoke-gvisor.md index a3255fd36..cb7a30086 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -78,11 +78,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 From 0abff6158b2ee3347e11fd0fda239f757c63806d Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 06:00:18 -0700 Subject: [PATCH 03/16] fix: use systemctl restart (not reload) after runsc install Docker's SIGHUP reload handler does NOT call setHostGatewayIP(), so the HostGatewayIPs config field remains empty after a reload-only config change. This causes --add-host host.docker.internal:host-gateway to fail with 'could not parse extra host IP invalid IP' for any container started after the reload (including the MCP Gateway). A full restart runs initNetworkController() which calls setHostGatewayIP() to populate the bridge gateway IP. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 82 +++++++++++++------------ .github/workflows/smoke-gvisor.md | 5 +- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index 2b3953b73..ef679f3d2 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":"e2ee3551b102f033f12b691db760c7e8d73d8e8eaba8b5cad8a8fae1c3b81285","body_hash":"351733cecc70a24556659b1cac6d0bb11163956cb9077a1e2cffc121226ca06f","compiler_version":"v0.82.7","agent_id":"copilot","engine_versions":{"copilot":"1.0.68"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d0fecce6c2eee54ea7df54b4d9cdacecb5bb3db0b6fa19015207b7f44b7428f2","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,30 +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) - 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 @@ -578,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 @@ -951,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 diff --git a/.github/workflows/smoke-gvisor.md b/.github/workflows/smoke-gvisor.md index cb7a30086..fd7fbdca1 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -89,7 +89,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::" From 3a65b9eafd647867282e465efc9b26ca0f30fac2 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 06:24:45 -0700 Subject: [PATCH 04/16] feat: add --container-runtime CLI flag for gVisor/alternative runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end support for running the agent container under an alternative OCI runtime (e.g. gVisor's runsc, Kata Containers): - Added runtime?: string to DockerService interface (types/docker.ts) - Added containerRuntime?: string to ContainerImageOptions - Added --container-runtime CLI flag - Added config file support (container.containerRuntime in awf.json) - Thread runtime through agent-service.ts → docker-compose.yml - Only agent container uses the custom runtime; infra containers (squid, api-proxy) always use the default runtime - Updated smoke-gvisor workflow: - Fixed gVisor download URL (raw uname -m, no arch remap) - Fixed systemctl restart (not reload) after runsc install - Postprocess injects --container-runtime runsc into AWF command - Verify step checks docker-compose.yml for runtime: runsc - Added 3 unit tests for runtime plumbing - All 3649 tests pass Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 20 ++++++++++++--- .github/workflows/smoke-gvisor.md | 16 ++++++++++-- scripts/ci/postprocess-smoke-workflows.ts | 19 ++++++++++++++ src/cli-options.ts | 5 ++++ src/commands/build-config.ts | 1 + src/commands/validate-options.test.ts | 1 + src/config-file.ts | 1 + src/config-mapper.ts | 1 + src/services/agent-service-build.test.ts | 31 +++++++++++++++++++++++ src/services/agent-service.ts | 6 +++++ src/types/container-image-options.ts | 14 ++++++++++ src/types/docker.ts | 12 +++++++++ 12 files changed, 121 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index ef679f3d2..97b7ecd9c 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":"d0fecce6c2eee54ea7df54b4d9cdacecb5bb3db0b6fa19015207b7f44b7428f2","body_hash":"351733cecc70a24556659b1cac6d0bb11163956cb9077a1e2cffc121226ca06f","compiler_version":"v0.82.7","agent_id":"copilot","engine_versions":{"copilot":"1.0.68"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8bfa432d743d545c130e9de3bc5874ba2c16afe10ee45940832489a859bd4ec9","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 # @@ -900,7 +900,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + awf --container-runtime runsc --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/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 @@ -1520,10 +1520,22 @@ jobs: 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 + # Check for gVisor kernel signature in agent logs (written to /proc/version inside gVisor) + if grep -R -qE 'gVisor' "$LOG_DIR" --include '*.log' 2>/dev/null; then echo "✅ gVisor runtime confirmed in agent logs" else - echo "⚠️ Could not confirm gVisor runtime in logs (expected until AWF runtime plumbing is added)" + echo "⚠️ Could not confirm gVisor runtime in agent logs (gVisor may not write to captured logs)" + # Check the docker-compose.yml artifact for runtime: runsc + COMPOSE_FILE="/tmp/gh-aw-agent/sandbox/firewall/docker-compose.yml" + if [ -f "$COMPOSE_FILE" ] && grep -q 'runtime: runsc' "$COMPOSE_FILE"; then + echo "✅ runtime: runsc confirmed in docker-compose.yml" + elif [ -f "$COMPOSE_FILE" ]; then + echo "::error::runtime: runsc NOT found in docker-compose.yml" + grep -A2 'runtime' "$COMPOSE_FILE" || echo "(no runtime field found)" + exit 1 + else + echo "⚠️ docker-compose.yml not found in artifacts" + fi fi echo "::endgroup::" - name: Token-usage sanity check diff --git a/.github/workflows/smoke-gvisor.md b/.github/workflows/smoke-gvisor.md index fd7fbdca1..5c2ea979a 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -64,10 +64,22 @@ jobs: 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 + # Check for gVisor kernel signature in agent logs (written to /proc/version inside gVisor) + if grep -R -qE 'gVisor' "$LOG_DIR" --include '*.log' 2>/dev/null; then echo "✅ gVisor runtime confirmed in agent logs" else - echo "⚠️ Could not confirm gVisor runtime in logs (expected until AWF runtime plumbing is added)" + echo "⚠️ Could not confirm gVisor runtime in agent logs (gVisor may not write to captured logs)" + # Check the docker-compose.yml artifact for runtime: runsc + COMPOSE_FILE="/tmp/gh-aw-agent/sandbox/firewall/docker-compose.yml" + if [ -f "$COMPOSE_FILE" ] && grep -q 'runtime: runsc' "$COMPOSE_FILE"; then + echo "✅ runtime: runsc confirmed in docker-compose.yml" + elif [ -f "$COMPOSE_FILE" ]; then + echo "::error::runtime: runsc NOT found in docker-compose.yml" + grep -A2 'runtime' "$COMPOSE_FILE" || echo "(no runtime field found)" + exit 1 + else + echo "⚠️ docker-compose.yml not found in artifacts" + fi fi echo "::endgroup::" - name: Token-usage sanity check diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts index 905d8adb2..32119ef50 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 runsc into the AWF command ──── +const gvisorLockPath = path.join(workflowsDir, 'smoke-gvisor.lock.yml'); +try { + let gvisorContent = fs.readFileSync(gvisorLockPath, 'utf-8'); + // Insert --container-runtime runsc before --container-workdir 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 runsc --config '); + if (replacedContent !== gvisorContent) { + fs.writeFileSync(gvisorLockPath, replacedContent); + console.log(` Injected --container-runtime runsc 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/cli-options.ts b/src/cli-options.ts index e7593b814..4e0948a59 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -169,6 +169,11 @@ program ' Useful for split runner/daemon filesystems (e.g. ARC DinD).\n' + ' Example: /host' ) + .option( + '--container-runtime ', + 'OCI container runtime for the agent container (e.g. runsc for gVisor, kata for Kata).\n' + + ' The runtime must be registered in the Docker daemon config.' + ) // -- Container Configuration -- .option( 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('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: 'runsc', + }; + 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: 'runsc', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + + expect((result.services.agent as any).runtime).toBe('runsc'); + expect((result.services['squid-proxy'] as any).runtime).toBeUndefined(); + }); + }); }); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 41aa59c3a..3cf37411a 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -160,6 +160,12 @@ export function buildAgentService(params: AgentServiceParams): any { Object.assign(agentService, resolveAgentImageConfig(config, imageConfig)); + // Set container runtime if specified (e.g., runsc for gVisor) + if (config.containerRuntime) { + agentService.runtime = config.containerRuntime; + logger.debug(`Set agent container runtime to: ${config.containerRuntime}`); + } + return agentService; } 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..0c06d9add 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -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; } /** From 576bebb036c6f242bdeb37e0f8cc1422d22667d9 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 07:02:45 -0700 Subject: [PATCH 05/16] fix: inject /etc/hosts entries for topology peers under gVisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gVisor's userspace netstack has an isolated sandbox loopback that cannot reach Docker's embedded DNS server at 127.0.0.11, causing container name resolution to fail with EAI_AGAIN (google/gvisor#7469, open since 2022). When containerRuntime is set (e.g., runsc for gVisor), AWF now: 1. Inspects topology-attached container IPs after connecting them to awf-net 2. Patches docker-compose.yml with extra_hosts entries for the agent 3. The agent resolves topology peers via /etc/hosts, bypassing DNS This runs between phase 2 (topology connect) and phase 3 (full compose up), so the agent picks up the entries before starting. Non-gVisor runs are unaffected — the patch only activates when containerRuntime is set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/cli-workflow.ts | 15 +++++- src/topology.test.ts | 107 +++++++++++++++++++++++++++++++++++++++++++ src/topology.ts | 80 ++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 6de703582..509c39cb9 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -3,7 +3,7 @@ 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 { TOPOLOGY_NETWORK_NAME, getTopologyContainerIps, patchComposeWithTopologyHosts } from './topology'; interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; @@ -119,6 +119,19 @@ 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 an alternative container runtime (e.g., gVisor), + // inject /etc/hosts entries for topology peers. gVisor's userspace + // netstack has an isolated loopback that cannot reach Docker's embedded + // DNS at 127.0.0.11, so container name resolution fails with EAI_AGAIN. + // Bypassing DNS via /etc/hosts (extra_hosts) resolves this. + // See: https://github.com/google/gvisor/issues/7469 + if (config.containerRuntime) { + const peerIps = await getTopologyContainerIps(TOPOLOGY_NETWORK_NAME, config.topologyAttach!); + if (peerIps.size > 0) { + patchComposeWithTopologyHosts(config.workDir, peerIps); + } + } } : undefined; diff --git a/src/topology.test.ts b/src/topology.test.ts index 3283b9c99..41ae6554f 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -1,8 +1,13 @@ import execa from 'execa'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; import { TOPOLOGY_NETWORK_NAME, assertTopologySupported, connectTopologyContainers, + getTopologyContainerIps, + patchComposeWithTopologyHosts, } from './topology'; jest.mock('execa'); @@ -147,4 +152,106 @@ 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 for topology peers', () => { + const compose = { + services: { + agent: { + container_name: 'awf-agent', + networks: { 'awf-net': { ipv4_address: '172.30.0.20' } }, + }, + }, + }; + const yaml = require('js-yaml'); + 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')); + expect(patched.services.agent.extra_hosts).toEqual(['mcp-gateway:172.30.0.40']); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('1 topology peer')); + }); + + it('appends to existing extra_hosts without overwriting', () => { + const compose = { + services: { + agent: { + container_name: 'awf-agent', + extra_hosts: ['host.docker.internal:host-gateway'], + }, + }, + }; + const yaml = require('js-yaml'); + 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')); + 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: {} } }; + const yaml = require('js-yaml'); + 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..c1a14da64 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,80 @@ 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) which is unreachable from gVisor's isolated sandbox loopback. + * + * 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.push(`${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 gVisor DNS compatibility`); +} From 219a3508ddc6debfc28122768639f3bc9e185c0c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 07:22:46 -0700 Subject: [PATCH 06/16] fix: propagate extra_hosts entries into chroot /etc/hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit injected extra_hosts into docker-compose.yml for gVisor DNS compatibility, but the agent runs chrooted to /host — so it reads /host/etc/hosts (the host's file), not Docker's /etc/hosts. The entrypoint now copies ALL non-standard entries from the container's /etc/hosts into the chroot's /etc/hosts. This covers: - Topology peer entries (awmg-mcpg, difc-proxy) for gVisor DNS bypass - Any future extra_hosts entries added by AWF Cleanup on exit removes both host.docker.internal and any 172.30.0.x (AWF subnet) entries that were injected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- containers/agent/entrypoint.sh | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index c3caff65e..6d610231e 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -876,6 +876,29 @@ setup_chroot_etc() { fi fi + # Inject topology peer extra_hosts entries into chroot's /etc/hosts. + # When the agent runs under an alternative container runtime (e.g., gVisor), + # Docker's embedded DNS (127.0.0.11) is unreachable from the sandbox's isolated + # loopback. AWF patches docker-compose.yml with extra_hosts entries so Docker + # writes them to /etc/hosts — but the chroot uses /host/etc/hosts instead. + # Copy any non-standard entries from the container's /etc/hosts to the chroot's. + # See: https://github.com/google/gvisor/issues/7469 + if [ -f /etc/hosts ] && [ -f /host/etc/hosts ]; then + # Extract entries that are NOT localhost, host.docker.internal, or the container's own hostname + EXTRA_ENTRIES=$(grep -vE '^\s*#|^\s*$|localhost|host\.docker\.internal|'"$(hostname)"'' /etc/hosts 2>/dev/null || true) + if [ -n "$EXTRA_ENTRIES" ]; then + while IFS= read -r entry; do + PEER_HOST=$(echo "$entry" | awk '{print $2}') + if [ -n "$PEER_HOST" ] && ! grep -q "$PEER_HOST" /host/etc/hosts 2>/dev/null; then + if echo "$entry" >> /host/etc/hosts 2>/dev/null; then + HOSTS_MODIFIED=true + echo "[entrypoint] Added topology peer to chroot /etc/hosts: $entry" + fi + fi + done <<< "$EXTRA_ENTRIES" + fi + fi + # Find the user name on the host system by UID # This allows us to run as the same user inside the chroot HOST_USER_UID="${AWF_CHROOT_IDENTITY_UID:-${AWF_USER_UID:-1000}}" @@ -1255,10 +1278,13 @@ run_chroot_command() { echo "[entrypoint] DNS configuration will be removed on exit" fi if [ "$HOSTS_MODIFIED" = "true" ]; then - # Remove the specific host.docker.internal line we added (runs inside chroot perspective) - # Use a precise pattern to avoid accidentally removing unrelated entries + # Remove entries we injected into /etc/hosts (runs inside chroot perspective). + # Remove host.docker.internal and any topology peer entries that came from + # Docker's extra_hosts (non-localhost, non-hostname entries). CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^[0-9.]\\+[[:space:]]\\+host\\.docker\\.internal\$/d' /etc/hosts 2>/dev/null || true" - echo "[entrypoint] host.docker.internal will be removed from /etc/hosts on exit" + # Remove topology peer entries (IPs in the AWF subnet 172.30.0.x) + CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^172\\.30\\.0\\./d' /etc/hosts 2>/dev/null || true" + echo "[entrypoint] Injected /etc/hosts entries will be removed on exit" fi # Clean up /tmp/awf-lib if anything was copied (one-shot-token, CA cert, key helper) if [ -n "${ONE_SHOT_TOKEN_LIB}" ] || [ -n "${AWF_CA_CHROOT}" ] || [ -n "${SYSTEM_CA_CHROOT}" ] || [ -n "${CHROOT_KEY_HELPER}" ] || [ -n "${STAGED_RUNNER_BINARY_CHROOT}" ]; then From 6dfaf05080a190df0b02a6696c979fb4fc497630 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 07:52:35 -0700 Subject: [PATCH 07/16] fix: patch chroot hosts file at config time, not entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chroot's /host/etc/hosts is a pre-generated file bind-mounted read-only — the entrypoint cannot append to it at runtime. Instead, patchComposeWithTopologyHosts() now also finds the hosts file source path from the compose volumes and appends topology peer entries directly to it on the host filesystem, before the container starts. This reverts the entrypoint changes from the previous commit and moves the logic to where it can actually write: the host-side compose patching step that runs between topology-connect and full compose up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- containers/agent/entrypoint.sh | 32 +++----------------------------- src/topology.test.ts | 14 ++++++++++++-- src/topology.ts | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 6d610231e..c3caff65e 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -876,29 +876,6 @@ setup_chroot_etc() { fi fi - # Inject topology peer extra_hosts entries into chroot's /etc/hosts. - # When the agent runs under an alternative container runtime (e.g., gVisor), - # Docker's embedded DNS (127.0.0.11) is unreachable from the sandbox's isolated - # loopback. AWF patches docker-compose.yml with extra_hosts entries so Docker - # writes them to /etc/hosts — but the chroot uses /host/etc/hosts instead. - # Copy any non-standard entries from the container's /etc/hosts to the chroot's. - # See: https://github.com/google/gvisor/issues/7469 - if [ -f /etc/hosts ] && [ -f /host/etc/hosts ]; then - # Extract entries that are NOT localhost, host.docker.internal, or the container's own hostname - EXTRA_ENTRIES=$(grep -vE '^\s*#|^\s*$|localhost|host\.docker\.internal|'"$(hostname)"'' /etc/hosts 2>/dev/null || true) - if [ -n "$EXTRA_ENTRIES" ]; then - while IFS= read -r entry; do - PEER_HOST=$(echo "$entry" | awk '{print $2}') - if [ -n "$PEER_HOST" ] && ! grep -q "$PEER_HOST" /host/etc/hosts 2>/dev/null; then - if echo "$entry" >> /host/etc/hosts 2>/dev/null; then - HOSTS_MODIFIED=true - echo "[entrypoint] Added topology peer to chroot /etc/hosts: $entry" - fi - fi - done <<< "$EXTRA_ENTRIES" - fi - fi - # Find the user name on the host system by UID # This allows us to run as the same user inside the chroot HOST_USER_UID="${AWF_CHROOT_IDENTITY_UID:-${AWF_USER_UID:-1000}}" @@ -1278,13 +1255,10 @@ run_chroot_command() { echo "[entrypoint] DNS configuration will be removed on exit" fi if [ "$HOSTS_MODIFIED" = "true" ]; then - # Remove entries we injected into /etc/hosts (runs inside chroot perspective). - # Remove host.docker.internal and any topology peer entries that came from - # Docker's extra_hosts (non-localhost, non-hostname entries). + # Remove the specific host.docker.internal line we added (runs inside chroot perspective) + # Use a precise pattern to avoid accidentally removing unrelated entries CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^[0-9.]\\+[[:space:]]\\+host\\.docker\\.internal\$/d' /etc/hosts 2>/dev/null || true" - # Remove topology peer entries (IPs in the AWF subnet 172.30.0.x) - CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^172\\.30\\.0\\./d' /etc/hosts 2>/dev/null || true" - echo "[entrypoint] Injected /etc/hosts entries will be removed on exit" + echo "[entrypoint] host.docker.internal will be removed from /etc/hosts on exit" fi # Clean up /tmp/awf-lib if anything was copied (one-shot-token, CA cert, key helper) if [ -n "${ONE_SHOT_TOKEN_LIB}" ] || [ -n "${AWF_CA_CHROOT}" ] || [ -n "${SYSTEM_CA_CHROOT}" ] || [ -n "${CHROOT_KEY_HELPER}" ] || [ -n "${STAGED_RUNNER_BINARY_CHROOT}" ]; then diff --git a/src/topology.test.ts b/src/topology.test.ts index 41ae6554f..11898f385 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -199,12 +199,19 @@ describe('topology', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('adds extra_hosts entries for topology peers', () => { + 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`], }, }, }; @@ -217,7 +224,10 @@ describe('topology', () => { const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')); expect(patched.services.agent.extra_hosts).toEqual(['mcp-gateway:172.30.0.40']); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining('1 topology peer')); + // 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', () => { diff --git a/src/topology.ts b/src/topology.ts index c1a14da64..b6ea752f6 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -210,4 +210,26 @@ export function patchComposeWithTopologyHosts( fs.writeFileSync(composePath, yaml.dump(compose, { lineWidth: -1 }), { mode: 0o600 }); log.info(`Patched docker-compose.yml with ${peerIps.size} topology peer host(s) for gVisor 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'); + } } From 7b05a14d62117017a1070b547776aae3deb67a8d Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 08:14:19 -0700 Subject: [PATCH 08/16] fix: inject compose-internal service hosts for gVisor DNS compatibility gVisor's isolated sandbox loopback cannot reach Docker's embedded DNS at 127.0.0.11, breaking resolution of compose service names like 'api-proxy' and 'squid-proxy'. Add extra_hosts entries for these compose-internal services when containerRuntime is set, mirroring the topology peer DNS fix but for services with statically-known IPs. Also simplifies the smoke-gvisor verify step to search all artifact types instead of depending on docker-compose.yml (which isn't in the upload list). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 25 ++++++-------------- .github/workflows/smoke-gvisor.md | 23 +++++-------------- src/cli-workflow.ts | 11 ++++++++- src/services/agent-service-build.test.ts | 29 ++++++++++++++++++++++++ src/services/agent-service.ts | 14 ++++++++++++ 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index 97b7ecd9c..499bd1e14 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":"8bfa432d743d545c130e9de3bc5874ba2c16afe10ee45940832489a859bd4ec9","body_hash":"351733cecc70a24556659b1cac6d0bb11163956cb9077a1e2cffc121226ca06f","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 # @@ -1518,24 +1518,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" - # Check for gVisor kernel signature in agent logs (written to /proc/version inside gVisor) - if grep -R -qE '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 agent logs (gVisor may not write to captured logs)" - # Check the docker-compose.yml artifact for runtime: runsc - COMPOSE_FILE="/tmp/gh-aw-agent/sandbox/firewall/docker-compose.yml" - if [ -f "$COMPOSE_FILE" ] && grep -q 'runtime: runsc' "$COMPOSE_FILE"; then - echo "✅ runtime: runsc confirmed in docker-compose.yml" - elif [ -f "$COMPOSE_FILE" ]; then - echo "::error::runtime: runsc NOT found in docker-compose.yml" - grep -A2 'runtime' "$COMPOSE_FILE" || echo "(no runtime field found)" - exit 1 - else - echo "⚠️ docker-compose.yml not found in artifacts" - fi + 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 5c2ea979a..951053b98 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -62,24 +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" - # Check for gVisor kernel signature in agent logs (written to /proc/version inside gVisor) - if grep -R -qE '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 agent logs (gVisor may not write to captured logs)" - # Check the docker-compose.yml artifact for runtime: runsc - COMPOSE_FILE="/tmp/gh-aw-agent/sandbox/firewall/docker-compose.yml" - if [ -f "$COMPOSE_FILE" ] && grep -q 'runtime: runsc' "$COMPOSE_FILE"; then - echo "✅ runtime: runsc confirmed in docker-compose.yml" - elif [ -f "$COMPOSE_FILE" ]; then - echo "::error::runtime: runsc NOT found in docker-compose.yml" - grep -A2 'runtime' "$COMPOSE_FILE" || echo "(no runtime field found)" - exit 1 - else - echo "⚠️ docker-compose.yml not found in artifacts" - fi + 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/src/cli-workflow.ts b/src/cli-workflow.ts index 509c39cb9..46e3fe6c0 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -2,7 +2,7 @@ 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 { CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP, API_PROXY_IP } from './host-iptables-shared'; import { TOPOLOGY_NETWORK_NAME, getTopologyContainerIps, patchComposeWithTopologyHosts } from './topology'; interface WorkflowDependencies { @@ -128,6 +128,15 @@ export async function runMainWorkflow( // See: https://github.com/google/gvisor/issues/7469 if (config.containerRuntime) { const peerIps = await getTopologyContainerIps(TOPOLOGY_NETWORK_NAME, config.topologyAttach!); + + // Also include compose-internal services whose hostnames the agent + // may need to resolve. Docker's embedded DNS (127.0.0.11) handles + // this normally, but gVisor's isolated loopback breaks it. + peerIps.set('squid-proxy', SQUID_IP); + if (config.enableApiProxy) { + peerIps.set('api-proxy', API_PROXY_IP); + } + if (peerIps.size > 0) { patchComposeWithTopologyHosts(config.workDir, peerIps); } diff --git a/src/services/agent-service-build.test.ts b/src/services/agent-service-build.test.ts index 2e220c60b..7da187136 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -561,5 +561,34 @@ describe('agent service', () => { 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: 'runsc', + 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).toContain(`squid-proxy:${mockNetworkConfig.squidIp}`); + expect(agent.extra_hosts).toContain('api-proxy:172.30.0.30'); + }); + + it('should not inject api-proxy host when proxyIp is absent', () => { + const configWithRuntime = { + ...mockConfig, + containerRuntime: 'runsc', + }; + const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); + const agent = result.services.agent as any; + + expect(agent.extra_hosts).toContain(`squid-proxy:${mockNetworkConfig.squidIp}`); + expect(agent.extra_hosts).not.toContain(expect.stringContaining('api-proxy')); + }); }); }); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 3cf37411a..8b2f3bf55 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -164,6 +164,20 @@ export function buildAgentService(params: AgentServiceParams): any { if (config.containerRuntime) { agentService.runtime = config.containerRuntime; logger.debug(`Set agent container runtime to: ${config.containerRuntime}`); + + // gVisor's userspace netstack has an isolated sandbox loopback that cannot + // reach Docker's embedded DNS at 127.0.0.11, so compose service name + // resolution fails (EAI_AGAIN). 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 (!agentService.extra_hosts) { + agentService.extra_hosts = []; + } + agentService.extra_hosts.push(`squid-proxy:${networkConfig.squidIp}`); + if (networkConfig.proxyIp) { + agentService.extra_hosts.push(`api-proxy:${networkConfig.proxyIp}`); + } + logger.debug('Injected compose-internal service hosts for gVisor DNS compatibility'); } return agentService; From 72559d502b555f0b7dd6301aa6304ae5c060d5b9 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 08:24:25 -0700 Subject: [PATCH 09/16] fix: use mapping format for extra_hosts (Docker Compose V2 compat) Docker Compose V2 on the CI runner requires extra_hosts as a mapping (dict), not a list of 'host:ip' strings. Convert all extra_hosts from string[] to Record format across agent, squid, cli-proxy, and topology patching. Error: 'services.agent.extra_hosts must be a mapping' Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/services/agent-environment-options.test.ts | 4 ++-- src/services/agent-service-build.test.ts | 10 ++++++---- src/services/agent-service.ts | 8 ++++---- src/services/cli-proxy-service.test.ts | 2 +- src/services/cli-proxy-service.ts | 2 +- src/services/squid-service.ts | 2 +- src/topology.test.ts | 12 ++++++------ src/topology.ts | 4 ++-- src/types/docker.ts | 8 ++++---- 9 files changed, 27 insertions(+), 25 deletions(-) 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 7da187136..e381914be 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -575,8 +575,10 @@ describe('agent service', () => { const result = generateDockerCompose(configWithRuntime, networkWithProxy); const agent = result.services.agent as any; - expect(agent.extra_hosts).toContain(`squid-proxy:${mockNetworkConfig.squidIp}`); - expect(agent.extra_hosts).toContain('api-proxy:172.30.0.30'); + 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', () => { @@ -587,8 +589,8 @@ describe('agent service', () => { const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); const agent = result.services.agent as any; - expect(agent.extra_hosts).toContain(`squid-proxy:${mockNetworkConfig.squidIp}`); - expect(agent.extra_hosts).not.toContain(expect.stringContaining('api-proxy')); + expect(agent.extra_hosts['squid-proxy']).toBe(mockNetworkConfig.squidIp); + expect(agent.extra_hosts['api-proxy']).toBeUndefined(); }); }); }); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 8b2f3bf55..c38c76cf1 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -154,7 +154,7 @@ 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'; } @@ -171,11 +171,11 @@ export function buildAgentService(params: AgentServiceParams): any { // compose-internal services the agent may need to reach by hostname. // See: https://github.com/google/gvisor/issues/7469 if (!agentService.extra_hosts) { - agentService.extra_hosts = []; + agentService.extra_hosts = {}; } - agentService.extra_hosts.push(`squid-proxy:${networkConfig.squidIp}`); + agentService.extra_hosts['squid-proxy'] = networkConfig.squidIp; if (networkConfig.proxyIp) { - agentService.extra_hosts.push(`api-proxy:${networkConfig.proxyIp}`); + agentService.extra_hosts['api-proxy'] = networkConfig.proxyIp; } logger.debug('Injected compose-internal service hosts for gVisor DNS compatibility'); } 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 11898f385..6d913efa6 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -223,7 +223,7 @@ describe('topology', () => { patchComposeWithTopologyHosts(tmpDir, peerIps, log); const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')); - expect(patched.services.agent.extra_hosts).toEqual(['mcp-gateway:172.30.0.40']); + 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'); @@ -235,7 +235,7 @@ describe('topology', () => { services: { agent: { container_name: 'awf-agent', - extra_hosts: ['host.docker.internal:host-gateway'], + extra_hosts: { 'host.docker.internal': 'host-gateway' }, }, }, }; @@ -247,10 +247,10 @@ describe('topology', () => { patchComposeWithTopologyHosts(tmpDir, peerIps, log); const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')); - expect(patched.services.agent.extra_hosts).toEqual([ - 'host.docker.internal:host-gateway', - 'mcp-gateway:172.30.0.40', - ]); + 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', () => { diff --git a/src/topology.ts b/src/topology.ts index b6ea752f6..7912ac8be 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -202,10 +202,10 @@ export function patchComposeWithTopologyHosts( } if (!agentService.extra_hosts) { - agentService.extra_hosts = []; + agentService.extra_hosts = {}; } for (const [name, ip] of peerIps) { - agentService.extra_hosts.push(`${name}:${ip}`); + agentService.extra_hosts[name] = ip; } fs.writeFileSync(composePath, yaml.dump(compose, { lineWidth: -1 }), { mode: 0o600 }); diff --git a/src/types/docker.ts b/src/types/docker.ts index 0c06d9add..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 From 48e3e4ac5c2304fc872ffcea421ff7b67d24b5ac Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 09:26:59 -0700 Subject: [PATCH 10/16] feat: add containerRuntime to AWF config schema Adds container.containerRuntime to the JSON schema so the compiler can set it via stdin config (e.g. container.containerRuntime: "runsc"). The field was already wired in config-file.ts and config-mapper.ts but missing from the schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/awf-config.schema.json | 4 ++++ src/awf-config-schema.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 75b14f573..6e586160e 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -619,6 +619,10 @@ "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", + "description": "OCI runtime for the agent container (e.g. \"runsc\" for gVisor). 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." } } }, diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 75b14f573..6e586160e 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -619,6 +619,10 @@ "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", + "description": "OCI runtime for the agent container (e.g. \"runsc\" for gVisor). 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." } } }, From fff77f1ffcbfe81acf3d620cbe65a85c03af8bf8 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 09:31:33 -0700 Subject: [PATCH 11/16] feat: accept "gvisor" as container runtime, translate to runsc AWF now accepts user-facing runtime names (e.g. "gvisor") and translates them to Docker OCI runtime identifiers (e.g. "runsc") via resolveDockerRuntime(). Unknown values pass through unchanged. The JSON schema now documents "gvisor" as the config value with an enum constraint. The compiler will set container.containerRuntime to "gvisor" in the stdin config when sandbox.agent.runtime is set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.lock.yml | 2 +- docs/awf-config.schema.json | 3 ++- scripts/ci/postprocess-smoke-workflows.ts | 8 ++++---- src/awf-config-schema.json | 3 ++- src/cli-options.ts | 5 +++-- src/services/agent-service-build.test.ts | 19 +++++++++++++++---- src/services/agent-service.ts | 23 ++++++++++++++++++++--- 7 files changed, 47 insertions(+), 16 deletions(-) diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index 499bd1e14..e8c49899b 100644 --- a/.github/workflows/smoke-gvisor.lock.yml +++ b/.github/workflows/smoke-gvisor.lock.yml @@ -900,7 +900,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --container-runtime runsc --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + awf --container-runtime gvisor --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/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 diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 6e586160e..df7b0cdae 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -622,7 +622,8 @@ }, "containerRuntime": { "type": "string", - "description": "OCI runtime for the agent container (e.g. \"runsc\" for gVisor). 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." + "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 32119ef50..9e73f9f67 100644 --- a/scripts/ci/postprocess-smoke-workflows.ts +++ b/scripts/ci/postprocess-smoke-workflows.ts @@ -75,17 +75,17 @@ for (const workflowPath of codexWorkflowPaths) { } } -// ── gVisor workflow: inject --container-runtime runsc into the AWF command ──── +// ── 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 runsc before --container-workdir on the awf command line. + // 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 runsc --config '); + const replacedContent = gvisorContent.replace(awfCmdPattern, 'awf --container-runtime gvisor --config '); if (replacedContent !== gvisorContent) { fs.writeFileSync(gvisorLockPath, replacedContent); - console.log(` Injected --container-runtime runsc into AWF command`); + console.log(` Injected --container-runtime gvisor into AWF command`); console.log(`Updated ${gvisorLockPath}`); } else { console.log(`Skipping ${gvisorLockPath}: no AWF command found to patch.`); diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 6e586160e..df7b0cdae 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -622,7 +622,8 @@ }, "containerRuntime": { "type": "string", - "description": "OCI runtime for the agent container (e.g. \"runsc\" for gVisor). 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." + "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 4e0948a59..144534e33 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -171,8 +171,9 @@ program ) .option( '--container-runtime ', - 'OCI container runtime for the agent container (e.g. runsc for gVisor, kata for Kata).\n' + - ' The runtime must be registered in the Docker daemon config.' + '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 -- diff --git a/src/services/agent-service-build.test.ts b/src/services/agent-service-build.test.ts index e381914be..4bc987fcf 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -543,7 +543,7 @@ describe('agent service', () => { it('should set runtime when containerRuntime is specified', () => { const configWithRuntime = { ...mockConfig, - containerRuntime: 'runsc', + containerRuntime: 'gvisor', }; const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); const agent = result.services.agent as any; @@ -554,7 +554,7 @@ describe('agent service', () => { it('should only set runtime on agent, not on squid', () => { const configWithRuntime = { ...mockConfig, - containerRuntime: 'runsc', + containerRuntime: 'gvisor', }; const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); @@ -565,7 +565,7 @@ describe('agent service', () => { it('should inject compose-internal service hosts when containerRuntime is set', () => { const configWithRuntime = { ...mockConfig, - containerRuntime: 'runsc', + containerRuntime: 'gvisor', enableApiProxy: true, }; const networkWithProxy = { @@ -584,7 +584,7 @@ describe('agent service', () => { it('should not inject api-proxy host when proxyIp is absent', () => { const configWithRuntime = { ...mockConfig, - containerRuntime: 'runsc', + containerRuntime: 'gvisor', }; const result = generateDockerCompose(configWithRuntime, mockNetworkConfig); const agent = result.services.agent as any; @@ -592,5 +592,16 @@ describe('agent service', () => { 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'); + }); }); }); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index c38c76cf1..ff8f13137 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -160,10 +160,10 @@ export function buildAgentService(params: AgentServiceParams): any { Object.assign(agentService, resolveAgentImageConfig(config, imageConfig)); - // Set container runtime if specified (e.g., runsc for gVisor) + // Set container runtime if specified (e.g., "gvisor" → Docker runtime "runsc") if (config.containerRuntime) { - agentService.runtime = config.containerRuntime; - logger.debug(`Set agent container runtime to: ${config.containerRuntime}`); + agentService.runtime = resolveDockerRuntime(config.containerRuntime); + logger.debug(`Set agent container runtime to: ${agentService.runtime} (from config: ${config.containerRuntime})`); // gVisor's userspace netstack has an isolated sandbox loopback that cannot // reach Docker's embedded DNS at 127.0.0.11, so compose service name @@ -183,6 +183,23 @@ export function buildAgentService(params: AgentServiceParams): any { return agentService; } +// ─── Runtime Resolution ────────────────────────────────────────────────────── + +/** Maps user-facing runtime names to Docker OCI runtime identifiers. */ +const RUNTIME_MAP: Record = { + gvisor: 'runsc', +}; + +/** + * Translates a user-facing container runtime name (e.g. "gvisor") into the + * corresponding Docker OCI runtime identifier (e.g. "runsc"). Values that + * don't have a mapping are passed through unchanged, allowing direct use of + * Docker runtime names when needed. + */ +export function resolveDockerRuntime(runtime: string): string { + return RUNTIME_MAP[runtime] ?? runtime; +} + // ─── Image Selection ───────────────────────────────────────────────────────── /** From 09f7a4a0660bf07acf85a500fc3e0e8175deb887 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 09:44:53 -0700 Subject: [PATCH 12/16] docs: add containerRuntime to awf-config-spec Documents the container.containerRuntime field in the stdin config specification alongside the existing container properties. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/awf-config-spec.md | 1 + 1 file changed, 1 insertion(+) 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)* From 03666f1cb8887a849e306dec15137f24a3876d36 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 09:49:38 -0700 Subject: [PATCH 13/16] refactor: extract container-runtime module with capability registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralises runtime name→Docker mapping and capability flags in a single RUNTIME_REGISTRY. DNS workaround logic now checks runtimeNeedsStaticDns() instead of testing containerRuntime as a truthy value, so adding a runtime that works with Docker DNS (e.g. kata) won't trigger unnecessary extra_hosts injection. To add a new runtime, add one entry to RUNTIME_REGISTRY — the rest of AWF (agent-service, cli-workflow, topology) picks it up via the capability flags automatically. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/cli-workflow.ts | 18 +++-- src/container-runtime.test.ts | 45 +++++++++++++ src/container-runtime.ts | 84 ++++++++++++++++++++++++ src/services/agent-service-build.test.ts | 17 +++++ src/services/agent-service.ts | 40 ++++------- src/topology.ts | 4 +- 6 files changed, 169 insertions(+), 39 deletions(-) create mode 100644 src/container-runtime.test.ts create mode 100644 src/container-runtime.ts diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 46e3fe6c0..bef2ec4ec 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -4,6 +4,7 @@ import { DEFAULT_DNS_SERVERS } from './dns-resolver'; import { parseDifcProxyHost } from './docker-manager'; 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'; interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; @@ -120,18 +121,15 @@ export async function runMainWorkflow( 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 an alternative container runtime (e.g., gVisor), - // inject /etc/hosts entries for topology peers. gVisor's userspace - // netstack has an isolated loopback that cannot reach Docker's embedded - // DNS at 127.0.0.11, so container name resolution fails with EAI_AGAIN. - // Bypassing DNS via /etc/hosts (extra_hosts) resolves this. - // See: https://github.com/google/gvisor/issues/7469 - if (config.containerRuntime) { + // 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!); - // Also include compose-internal services whose hostnames the agent - // may need to resolve. Docker's embedded DNS (127.0.0.11) handles - // this normally, but gVisor's isolated loopback breaks it. + // 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); diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts new file mode 100644 index 000000000..1cc80b98a --- /dev/null +++ b/src/container-runtime.test.ts @@ -0,0 +1,45 @@ +import { resolveDockerRuntime, getRuntimeCapabilities, runtimeNeedsStaticDns } from './container-runtime'; + +describe('container-runtime', () => { + 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); + }); + + 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); + }); + }); +}); diff --git a/src/container-runtime.ts b/src/container-runtime.ts new file mode 100644 index 000000000..e10c14680 --- /dev/null +++ b/src/container-runtime.ts @@ -0,0 +1,84 @@ +/** + * Container runtime resolution and capability detection. + * + * Centralises two 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. Today the only flag is `needsStaticDns`, which + * tells AWF to inject `/etc/hosts` entries for every service hostname + * because the runtime's network stack cannot reach Docker's embedded DNS + * at 127.0.0.11. + * + * To add a new runtime, add an entry to {@link RUNTIME_REGISTRY}. + */ + +// ─── Registry ──────────────────────────────────────────────────────────────── + +/** Behavioural capabilities / quirks for a container runtime. */ +export interface RuntimeCapabilities { + /** Docker OCI runtime identifier (set on docker-compose `runtime:` key). */ + readonly dockerRuntime: string; + + /** + * When `true`, Docker's embedded DNS (127.0.0.11) is unreachable from inside + * the container. 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: { + dockerRuntime: 'runsc', + needsStaticDns: true, + }, + // Example: a hypothetical runtime that uses standard Docker DNS + // kata: { dockerRuntime: 'kata-runtime', needsStaticDns: false }, +}; + +// ─── 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. + */ +export function resolveDockerRuntime(runtime: string): string { + return RUNTIME_REGISTRY[runtime]?.dockerRuntime ?? 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 container. + * + * 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; +} diff --git a/src/services/agent-service-build.test.ts b/src/services/agent-service-build.test.ts index 4bc987fcf..aea10aed2 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -603,5 +603,22 @@ describe('agent service', () => { 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 ff8f13137..f86c5dee1 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'; @@ -165,41 +166,26 @@ export function buildAgentService(params: AgentServiceParams): any { agentService.runtime = resolveDockerRuntime(config.containerRuntime); logger.debug(`Set agent container runtime to: ${agentService.runtime} (from config: ${config.containerRuntime})`); - // gVisor's userspace netstack has an isolated sandbox loopback that cannot - // reach Docker's embedded DNS at 127.0.0.11, so compose service name - // resolution fails (EAI_AGAIN). Inject static /etc/hosts entries for all + // 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 (!agentService.extra_hosts) { - agentService.extra_hosts = {}; + 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'); } - 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 gVisor DNS compatibility'); } return agentService; } -// ─── Runtime Resolution ────────────────────────────────────────────────────── - -/** Maps user-facing runtime names to Docker OCI runtime identifiers. */ -const RUNTIME_MAP: Record = { - gvisor: 'runsc', -}; - -/** - * Translates a user-facing container runtime name (e.g. "gvisor") into the - * corresponding Docker OCI runtime identifier (e.g. "runsc"). Values that - * don't have a mapping are passed through unchanged, allowing direct use of - * Docker runtime names when needed. - */ -export function resolveDockerRuntime(runtime: string): string { - return RUNTIME_MAP[runtime] ?? runtime; -} - // ─── Image Selection ───────────────────────────────────────────────────────── /** diff --git a/src/topology.ts b/src/topology.ts index 7912ac8be..4f85dfc0c 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -180,7 +180,7 @@ export async function getTopologyContainerIps( /** * 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) which is unreachable from gVisor's isolated sandbox loopback. + * (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 @@ -209,7 +209,7 @@ export function patchComposeWithTopologyHosts( } fs.writeFileSync(composePath, yaml.dump(compose, { lineWidth: -1 }), { mode: 0o600 }); - log.info(`Patched docker-compose.yml with ${peerIps.size} topology peer host(s) for gVisor DNS compatibility`); + 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. From 06c991db3680d4e50e6bcec363ddf8e0c98e183b Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 10:09:53 -0700 Subject: [PATCH 14/16] docs: add future sbx integration notes to container-runtime module Documents the architectural boundary between OCI runtimes (this module) and future microVM backends (Docker sbx). The integration point for sbx is WorkflowDependencies in cli-workflow.ts, not the runtime registry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/container-runtime.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/container-runtime.ts b/src/container-runtime.ts index e10c14680..542f8e3bf 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -8,12 +8,30 @@ * 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. Today the only flag is `needsStaticDns`, which - * tells AWF to inject `/etc/hosts` entries for every service hostname - * because the runtime's network stack cannot reach Docker's embedded DNS - * at 127.0.0.11. + * AWF must compensate for. Flags include: + * - `needsStaticDns` – runtime cannot reach Docker's embedded DNS at + * 127.0.0.11, so AWF must inject `/etc/hosts` entries for every service. * - * To add a new runtime, add an entry to {@link RUNTIME_REGISTRY}. + * ## Adding a new OCI runtime + * + * Add an entry to {@link RUNTIME_REGISTRY} with 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. + * + * ## Future: non-OCI execution backends (e.g. Docker sbx microVMs) + * + * Docker sbx (`sbx run`) uses hypervisor-isolated microVMs with their own + * Docker daemon, proxy, and credential injection — a fundamentally different + * execution model from Docker Compose + OCI runtimes. When sbx support is + * added, the integration point is higher up the stack: + * + * - `cli-workflow.ts`'s `WorkflowDependencies` interface already decouples + * the orchestration from Docker Compose. An `SbxBackend` would implement + * the same dependency contract with `sbx create/run/rm` calls. + * - This module stays focused on OCI runtime resolution. Sbx would bypass + * it entirely since it doesn't use Docker's `runtime:` field. + * - AWF components that sbx already provides (proxy, credential injection) + * would be skipped via a backend-level check, not a runtime capability flag. */ // ─── Registry ──────────────────────────────────────────────────────────────── From 06b5e04cccc06c22b4f45422a8451772b766f306 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 10:25:47 -0700 Subject: [PATCH 15/16] feat: add ExecutionModel type and runtimeUsesComposeAgent() for sbx readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the execution model concept to the runtime registry: - "compose" — agent is a Docker Compose service (runc, gVisor, kata) - "microvm" — agent runs in a hypervisor-isolated VM (Docker sbx) runtimeUsesComposeAgent() lets callers decide whether to include the agent service in docker-compose.yml. Infrastructure services (Squid, api-proxy) are always generated regardless of execution model, since the microVM model keeps them on the host for token logging, model routing, and credential injection — the sbx proxy chains upstream through AWF's host-side infrastructure. No behavioural changes for existing runtimes. The sbx entry is commented out in the registry as a placeholder until implementation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/cli-workflow.ts | 12 ++++ src/container-runtime.test.ts | 18 +++++- src/container-runtime.ts | 104 +++++++++++++++++++++++++--------- src/services/agent-service.ts | 7 ++- 4 files changed, 110 insertions(+), 31 deletions(-) diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index bef2ec4ec..ac42be7b8 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -6,6 +6,18 @@ import { CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP, API_PROXY_IP } from './host-iptab 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; diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 1cc80b98a..4fa1e99e9 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -1,4 +1,4 @@ -import { resolveDockerRuntime, getRuntimeCapabilities, runtimeNeedsStaticDns } from './container-runtime'; +import { resolveDockerRuntime, getRuntimeCapabilities, runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runtime'; describe('container-runtime', () => { describe('resolveDockerRuntime', () => { @@ -19,6 +19,7 @@ describe('container-runtime', () => { expect(caps).toBeDefined(); expect(caps!.dockerRuntime).toBe('runsc'); expect(caps!.needsStaticDns).toBe(true); + expect(caps!.executionModel).toBe('compose'); }); it('returns undefined for unknown runtimes', () => { @@ -42,4 +43,19 @@ describe('container-runtime', () => { 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 index 542f8e3bf..358f1555c 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -1,50 +1,71 @@ /** * Container runtime resolution and capability detection. * - * Centralises two concerns: + * 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. Flags include: + * 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. * - * ## Adding a new OCI runtime + * 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. * - * Add an entry to {@link RUNTIME_REGISTRY} with 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 new OCI runtime * - * ## Future: non-OCI execution backends (e.g. Docker sbx microVMs) + * 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. * - * Docker sbx (`sbx run`) uses hypervisor-isolated microVMs with their own - * Docker daemon, proxy, and credential injection — a fundamentally different - * execution model from Docker Compose + OCI runtimes. When sbx support is - * added, the integration point is higher up the stack: + * ## Adding a microVM backend (e.g. Docker sbx) * - * - `cli-workflow.ts`'s `WorkflowDependencies` interface already decouples - * the orchestration from Docker Compose. An `SbxBackend` would implement - * the same dependency contract with `sbx create/run/rm` calls. - * - This module stays focused on OCI runtime resolution. Sbx would bypass - * it entirely since it doesn't use Docker's `runtime:` field. - * - AWF components that sbx already provides (proxy, credential injection) - * would be skipped via a backend-level check, not a runtime capability flag. + * 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 { - /** Docker OCI runtime identifier (set on docker-compose `runtime:` key). */ - readonly dockerRuntime: string; + /** 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 container. AWF compensates by injecting static `/etc/hosts` entries - * for all compose-internal services and topology peers. + * 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 @@ -62,11 +83,16 @@ export interface RuntimeCapabilities { */ const RUNTIME_REGISTRY: Readonly> = { gvisor: { + executionModel: 'compose', dockerRuntime: 'runsc', needsStaticDns: true, }, - // Example: a hypothetical runtime that uses standard Docker DNS - // kata: { dockerRuntime: 'kata-runtime', needsStaticDns: false }, + // Future: Docker sbx microVM backend + // sbx: { + // executionModel: 'microvm', + // dockerRuntime: undefined, + // needsStaticDns: false, // sbx manages its own DNS + // }, }; // ─── Public API ────────────────────────────────────────────────────────────── @@ -74,10 +100,15 @@ const RUNTIME_REGISTRY: Readonly> = { /** * 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. + * 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 { - return RUNTIME_REGISTRY[runtime]?.dockerRuntime ?? runtime; +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; } /** @@ -91,7 +122,7 @@ export function getRuntimeCapabilities(runtime: string): RuntimeCapabilities | u /** * 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 container. + * unreachable from inside the agent environment. * * Returns `false` for unknown runtimes (passthrough names) — they are assumed * to work with Docker's standard DNS. @@ -100,3 +131,20 @@ 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-service.ts b/src/services/agent-service.ts index f86c5dee1..1c7c2ae02 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -163,8 +163,11 @@ export function buildAgentService(params: AgentServiceParams): any { // Set container runtime if specified (e.g., "gvisor" → Docker runtime "runsc") if (config.containerRuntime) { - agentService.runtime = resolveDockerRuntime(config.containerRuntime); - logger.debug(`Set agent container runtime to: ${agentService.runtime} (from config: ${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. From 7d761988bf3240f7d096bc991cc3b39b3d96b0cc Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 10 Jul 2026 10:34:35 -0700 Subject: [PATCH 16/16] fix: resolve CI lint errors in topology tests and gVisor workflow - Replace require('js-yaml') with top-level import in topology.test.ts - Cast yaml.load() return to any to fix TS18046 - Convert fenced code block to indented in smoke-gvisor.md (MD046) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-gvisor.md | 4 +--- src/topology.test.ts | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/smoke-gvisor.md b/.github/workflows/smoke-gvisor.md index 951053b98..509c5ab9a 100644 --- a/.github/workflows/smoke-gvisor.md +++ b/.github/workflows/smoke-gvisor.md @@ -204,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/src/topology.test.ts b/src/topology.test.ts index 6d913efa6..bb1561eb0 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -2,6 +2,7 @@ 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, @@ -215,14 +216,13 @@ describe('topology', () => { }, }, }; - const yaml = require('js-yaml'); 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')); + 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'); @@ -239,14 +239,13 @@ describe('topology', () => { }, }, }; - const yaml = require('js-yaml'); 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')); + 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', @@ -255,7 +254,6 @@ describe('topology', () => { it('warns and returns when agent service is not found', () => { const compose = { services: { squid: {} } }; - const yaml = require('js-yaml'); fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); const log = { info: jest.fn(), warn: jest.fn() };