diff --git a/.github/workflows/smoke-docker-sbx.lock.yml b/.github/workflows/smoke-docker-sbx.lock.yml index 8a260c5fb..68c061e91 100644 --- a/.github/workflows/smoke-docker-sbx.lock.yml +++ b/.github/workflows/smoke-docker-sbx.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2aa04abd7302afdaf0513c4258d8643f2b4951947e257a1517e60bb6bfef0173","body_hash":"831b52f3f9d8ef07ec152f72dd2e49d5c8e208a8742a3f940de10c8f5653898d","compiler_version":"v0.82.8","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":"99d9d888952ee25fce70c6b3120ca490d7d8da95","version":"v0.82.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.29","digest":"sha256:debc0b18ef8ea3a64585c4d1eea1099f0d9fa76b53d34a1f3c53b3225fe158fe","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.29@sha256:debc0b18ef8ea3a64585c4d1eea1099f0d9fa76b53d34a1f3c53b3225fe158fe"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.29","digest":"sha256:c7754df3f06f346c817db0525ba523cbdaf5349239fd7f37897c4250a8fc7bde","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.29@sha256:c7754df3f06f346c817db0525ba523cbdaf5349239fd7f37897c4250a8fc7bde"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.29","digest":"sha256:7bfa0742f9a5bd6309507caaa80a8b6cf3e05bd95a1429affbf64cc94cfbd34f","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.29@sha256:7bfa0742f9a5bd6309507caaa80a8b6cf3e05bd95a1429affbf64cc94cfbd34f"},{"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.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2aa04abd7302afdaf0513c4258d8643f2b4951947e257a1517e60bb6bfef0173","body_hash":"831b52f3f9d8ef07ec152f72dd2e49d5c8e208a8742a3f940de10c8f5653898d","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 # # ___ _ _ # / _ \ | | (_) @@ -569,6 +569,72 @@ jobs: exec "${NODE_BIN}" "${WORKSPACE_PATH}/dist/cli.js" "\$@" EOF sudo chmod +x /usr/local/bin/awf + - name: Install Docker sbx CLI + run: | + set -euo pipefail + echo "::group::Install Docker sbx" + # Add Docker apt repo (REPO_ONLY=1 skips installing Docker Engine) + curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh + sudo apt-get install -y docker-sbx + sbx version + echo "::endgroup::" + + echo "::group::Verify KVM availability" + if lsmod | grep -q kvm; then + echo "✅ KVM is available" + # Ensure runner user can access /dev/kvm (may not be in kvm group) + if [ -w /dev/kvm ]; then + echo "✅ /dev/kvm is writable" + else + echo "Fixing /dev/kvm permissions..." + sudo chmod 666 /dev/kvm + fi + else + echo "⚠️ KVM not available — sbx will not start" + kvm-ok 2>&1 || true + fi + echo "::endgroup::" + - name: Authenticate Docker sbx + env: + DOCKER_PAT_VAL: ${{ secrets.DOCKER_PAT }} + DOCKER_USERNAME_VAL: ${{ secrets.DOCKER_USERNAME }} + run: | + set -euo pipefail + + # Start daemon in background + nohup sbx daemon start > /tmp/sbx-daemon.log 2>&1 & + disown + for i in $(seq 1 10); do + if sbx daemon status 2>/dev/null | grep -q "running"; then break; fi + sleep 1 + done + + # Authenticate with Docker Hub + printf '%s' "$DOCKER_PAT_VAL" | docker login --username "$DOCKER_USERNAME_VAL" --password-stdin + printf '%s' "$DOCKER_PAT_VAL" | sbx login --username "$DOCKER_USERNAME_VAL" --password-stdin + + # Reset policy store and re-initialize (required for mount policy) + sbx daemon stop || true + sbx policy reset --force || true + sbx policy init allow-all + nohup sbx daemon start > /tmp/sbx-daemon.log 2>&1 & + disown + for i in $(seq 1 10); do + if sbx daemon status 2>/dev/null | grep -q "running"; then break; fi + sleep 1 + done + # Re-authenticate after daemon restart + printf '%s' "$DOCKER_PAT_VAL" | sbx login --username "$DOCKER_USERNAME_VAL" --password-stdin + + # Pre-pull template image into sbx's containerd cache + docker pull docker/sandbox-templates:shell-docker + + # Smoke test: create → exec → cleanup + bash -c 'yes | sbx create shell --name test-sandbox-direct "$GITHUB_WORKSPACE" 2>&1' || true + sbx exec test-sandbox-direct uname -a + sbx stop test-sandbox-direct 2>/dev/null || true + sbx rm --force test-sandbox-direct 2>/dev/null || true + echo "✅ sbx ready" - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) @@ -754,7 +820,7 @@ jobs: # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="8080" export MCP_GATEWAY_DOMAIN="awmg-mcpg" - export MCP_GATEWAY_HOST_DOMAIN="localhost" + export MCP_GATEWAY_HOST_DOMAIN="host.docker.internal" MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${MCP_GATEWAY_API_KEY}" export MCP_GATEWAY_API_KEY @@ -766,8 +832,13 @@ jobs: export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') - source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.3.32' + case "${DOCKER_HOST:-}" in + unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; + /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; + * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; + esac + DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 0.0.0.0:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.3.32' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) @@ -826,7 +897,7 @@ jobs: }, "gateway": { "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", + "domain": "${MCP_GATEWAY_HOST_DOMAIN}", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } @@ -837,7 +908,7 @@ jobs: continue-on-error: true env: MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_DOMAIN: host.docker.internal MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -853,6 +924,15 @@ jobs: id: pre_agent_audit continue-on-error: true run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Refresh sbx credentials + env: + DOCKER_PAT_VAL: ${{ secrets.DOCKER_PAT }} + DOCKER_USERNAME_VAL: ${{ secrets.DOCKER_USERNAME }} + run: | + # Re-authenticate sbx immediately before AWF runs. + # Docker Hub OAuth tokens from sbx login can expire between steps. + printf '%s' "$DOCKER_PAT_VAL" | sbx login --username "$DOCKER_USERNAME_VAL" --password-stdin + echo "✅ sbx credentials refreshed" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -889,7 +969,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 sbx --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-spec.md b/docs/awf-config-spec.md index ee4437679..62836dbca 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -183,7 +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.)* +- `container.containerRuntime` → `--container-runtime` *(user-facing runtime name: `"gvisor"` for OCI runtime in compose, `"sbx"` for Docker sbx microVM. For gvisor: translates to `"runsc"`, injects `extra_hosts` for DNS workaround. For sbx: agent runs in a hypervisor-isolated microVM, infra stays in compose, sbx proxy chains through AWF's Squid.)* - `chroot.binariesSourcePath` → *(config-only; overlays a runner-side binaries directory at `/usr/local/bin` inside chroot mode)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index df7b0cdae..0ad0345cf 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -622,8 +622,8 @@ }, "containerRuntime": { "type": "string", - "enum": ["gvisor"], - "description": "Container runtime for the agent container. Set to \"gvisor\" to run the agent under gVisor's runsc runtime for additional sandboxing. Only the agent container uses the custom runtime; infrastructure containers (squid-proxy, api-proxy) always use the default runc runtime. When set, AWF automatically injects extra_hosts entries for compose-internal services to work around DNS resolution issues with non-default runtimes. Requires gVisor (runsc) to be installed and registered with Docker on the host." + "enum": ["gvisor", "sbx"], + "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." } } }, diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts index 9e73f9f67..30d3086a8 100644 --- a/scripts/ci/postprocess-smoke-workflows.ts +++ b/scripts/ci/postprocess-smoke-workflows.ts @@ -75,14 +75,14 @@ for (const workflowPath of codexWorkflowPaths) { } } -// ── gVisor workflow: inject --container-runtime gvisor into the AWF command ─── +// ── Runtime workflow patching: inject --container-runtime into AWF commands ─── +// The compiler doesn't support sandbox.agent.containerRuntime yet, so we inject it here. +const runtimeCmdPattern = /awf --config /g; + const gvisorLockPath = path.join(workflowsDir, 'smoke-gvisor.lock.yml'); try { - let gvisorContent = fs.readFileSync(gvisorLockPath, 'utf-8'); - // Insert --container-runtime gvisor before --config on the awf command line. - // The compiler doesn't support sandbox.agent.containerRuntime yet, so we inject it here. - const awfCmdPattern = /awf --config /g; - const replacedContent = gvisorContent.replace(awfCmdPattern, 'awf --container-runtime gvisor --config '); + const gvisorContent = fs.readFileSync(gvisorLockPath, 'utf-8'); + const replacedContent = gvisorContent.replace(runtimeCmdPattern, 'awf --container-runtime gvisor --config '); if (replacedContent !== gvisorContent) { fs.writeFileSync(gvisorLockPath, replacedContent); console.log(` Injected --container-runtime gvisor into AWF command`); @@ -93,3 +93,19 @@ try { } catch { console.log(`Skipping ${gvisorLockPath}: file not found.`); } + +const sbxLockPath = path.join(workflowsDir, 'smoke-docker-sbx.lock.yml'); +try { + const sbxContent = fs.readFileSync(sbxLockPath, 'utf-8'); + runtimeCmdPattern.lastIndex = 0; + const sbxReplacedContent = sbxContent.replace(runtimeCmdPattern, 'awf --container-runtime sbx --config '); + if (sbxReplacedContent !== sbxContent) { + fs.writeFileSync(sbxLockPath, sbxReplacedContent); + console.log(` Injected --container-runtime sbx into AWF command`); + console.log(`Updated ${sbxLockPath}`); + } else { + console.log(`Skipping ${sbxLockPath}: no AWF command found to patch.`); + } +} catch { + console.log(`Skipping ${sbxLockPath}: file not found.`); +} diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index df7b0cdae..0ad0345cf 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -622,8 +622,8 @@ }, "containerRuntime": { "type": "string", - "enum": ["gvisor"], - "description": "Container runtime for the agent container. Set to \"gvisor\" to run the agent under gVisor's runsc runtime for additional sandboxing. Only the agent container uses the custom runtime; infrastructure containers (squid-proxy, api-proxy) always use the default runc runtime. When set, AWF automatically injects extra_hosts entries for compose-internal services to work around DNS resolution issues with non-default runtimes. Requires gVisor (runsc) to be installed and registered with Docker on the host." + "enum": ["gvisor", "sbx"], + "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." } } }, diff --git a/src/cli-options.ts b/src/cli-options.ts index 144534e33..9208f47f9 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -171,9 +171,10 @@ program ) .option( '--container-runtime ', - 'Container runtime for the agent container (e.g. "gvisor" for gVisor sandboxing).\n' + - ' AWF translates friendly names to Docker runtime identifiers\n' + - ' (gvisor → runsc). Unknown values are passed through as-is.' + 'Container runtime for the agent container.\n' + + ' "gvisor" — OCI runtime via Docker Compose (translates to runsc).\n' + + ' "sbx" — Docker sbx microVM with hypervisor isolation.\n' + + ' Unknown values are passed through as raw Docker runtime names.' ) // -- Container Configuration -- diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 97fa69849..e3d4e8178 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -33,6 +33,7 @@ jest.mock('../dind-bootstrap'); jest.mock('./preflight'); jest.mock('./signal-handler'); jest.mock('./validate-options'); +jest.mock('../sbx-manager'); import { logger } from '../logger'; import * as dockerManager from '../docker-manager'; @@ -45,6 +46,7 @@ import * as dindBootstrap from '../dind-bootstrap'; import * as preflight from './preflight'; import * as signalHandler from './signal-handler'; import * as validateOptions from './validate-options'; +import * as sbxManager from '../sbx-manager'; const mockedLogger = logger as jest.Mocked; const mockedDockerManager = dockerManager as jest.Mocked; @@ -57,6 +59,7 @@ const mockedDindBootstrap = dindBootstrap as jest.Mocked; const mockedPreflight = preflight as jest.Mocked; const mockedSignalHandler = signalHandler as jest.Mocked; const mockedValidateOptions = validateOptions as jest.Mocked; +const mockedSbxManager = sbxManager as jest.Mocked; /** Minimal WrapperConfig returned by the validateOptions mock. */ const STUB_CONFIG = { @@ -108,6 +111,10 @@ describe('createMainAction', () => { mockedDindBootstrap.runDindBootstrap.mockResolvedValue(undefined); mockedSignalHandler.registerSignalHandlers.mockImplementation(() => {}); mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0); + mockedSbxManager.isSbxAvailable.mockResolvedValue(true); + mockedSbxManager.createSandbox.mockResolvedValue('awf-agent-test'); + mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); + mockedSbxManager.removeSandbox.mockResolvedValue(undefined); }); afterEach(() => { @@ -292,6 +299,45 @@ describe('createMainAction', () => { await action(['curl https://example.com'], {}); expect(processExitSpy).toHaveBeenCalledWith(42); }); + + describe('sbx runtime wiring', () => { + it('passes configured mounts/workdir/environment into sbx create/exec', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/home/runner/work/repo/repo', + volumeMounts: ['/tmp/tooling:/tmp/tooling:ro'], + enableApiProxy: true, + tty: true, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps, _callbacks) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com'], undefined, 10); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith(expect.objectContaining({ + extraMounts: ['/tmp/tooling:/tmp/tooling:ro'], + })); + expect(mockedSbxManager.execInSandbox).toHaveBeenCalledWith( + 'awf-agent-test', + 'echo hi', + expect.objectContaining({ + timeoutMinutes: 10, + workDir: '/home/runner/work/repo/repo', + tty: true, + environment: expect.objectContaining({ + HTTPS_PROXY: expect.any(String), + SQUID_PROXY_HOST: expect.any(String), + }), + }), + ); + }); + }); }); describe('when runMainWorkflow throws', () => { diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 8d796cf7a..9c92f496c 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -26,7 +26,19 @@ import { validateOptions } from './validate-options'; import { probeSplitFilesystem } from '../dind-probe'; import { assertTopologySupported, connectTopologyContainers } from '../topology'; import { runDindBootstrap } from '../dind-bootstrap'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { createSandbox, execInSandbox, removeSandbox, isSbxAvailable, SBX_DEFAULT_NAME } from '../sbx-manager'; import type { WrapperConfig } from '../types'; +import { buildAgentEnvironment } from '../services/agent-service'; +import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; +import { DEFAULT_DNS_SERVERS } from '../dns-resolver'; +import { AGENT_IP, CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP } from '../host-iptables-shared'; + +/** Report whether a secret is set (and its length) without exposing the value. */ +function redactSecret(value: string | undefined): string { + if (!value) return '(unset)'; + return `(set, len=${value.length})`; +} const SENSITIVE_CONFIG_KEYS = new Set([ 'openaiApiKey', @@ -89,6 +101,15 @@ function buildCleanupFn( logger.info(`Received ${signal}, cleaning up...`); } + // Clean up sbx sandbox if using microVM runtime + if (!runtimeUsesComposeAgent(config.containerRuntime) && !config.keepContainers) { + try { + await removeSandbox(SBX_DEFAULT_NAME); + } catch { + // Sandbox may not exist yet — that's fine + } + } + // Copy iptables audit BEFORE stopping containers (volumes are destroyed by `docker compose down -v`) if (getContainersStarted()) { preserveIptablesAudit(config.workDir, config.auditDir); @@ -231,14 +252,154 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { }); try { + // For sbx (microVM) runtime, wrap startContainers and runAgentCommand + // to launch the agent in a sandbox instead of Docker Compose. + const useSbx = !runtimeUsesComposeAgent(config.containerRuntime); + let sbxName: string | undefined; + let sbxEnvironment: Record | undefined; + + const sbxStartContainers = useSbx + ? async (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean, onNetworkReady?: () => Promise) => { + // Start infra-only compose (squid, api-proxy — no agent service) + await startContainers(workDir, allowedDomains, proxyLogsDir, skipPull, onNetworkReady); + + // Verify sbx is available + if (!await isSbxAvailable()) { + throw new Error('Docker sbx CLI not found. Install sbx to use --container-runtime sbx.'); + } + + // For sbx, the microVM can't reach Docker internal IPs (172.30.0.x). + // Published Squid port (3128) is accessible via the sbx gateway IP. + // The api-proxy is on the awf-ext bridge network and reachable from + // inside the sbx via `host.docker.internal` (resolves to the docker0 + // bridge IP, typically 172.17.0.1). + const SBX_GATEWAY_IP = '172.17.0.0'; + const SBX_HOST_DOCKER_INTERNAL = 'host.docker.internal'; + + sbxEnvironment = buildAgentEnvironment({ + config, + networkConfig: { + subnet: '172.30.0.0/24', + squidIp: SBX_GATEWAY_IP, + agentIp: AGENT_IP, + proxyIp: config.enableApiProxy ? SBX_HOST_DOCKER_INTERNAL : undefined, + dohProxyIp: config.dnsOverHttps ? DOH_PROXY_IP : undefined, + cliProxyIp: config.difcProxyHost ? CLI_PROXY_IP : undefined, + }, + dnsServers: config.dnsServers || DEFAULT_DNS_SERVERS, + }); + + // Merge credential isolation env vars (COPILOT_API_URL, COPILOT_PROVIDER_BASE_URL, etc.) + // In Docker mode these are merged by assembleOptionalServices during compose generation. + // For sbx, we call buildAgentCredentialEnv directly with host.docker.internal + // as the proxy target (the api-proxy is on the awf-ext bridge network). + if (config.enableApiProxy) { + const credentialEnv = buildAgentCredentialEnv({ + config, + networkConfig: { + subnet: '172.30.0.0/24', + squidIp: SBX_GATEWAY_IP, + agentIp: AGENT_IP, + proxyIp: SBX_HOST_DOCKER_INTERNAL, + }, + }); + Object.assign(sbxEnvironment, credentialEnv); + } + + // Log critical env vars for debugging auth flow (redact secret values) + logger.info(`[sbx-env] COPILOT_API_URL=${sbxEnvironment.COPILOT_API_URL || '(unset)'}`); + logger.info(`[sbx-env] COPILOT_PROVIDER_BASE_URL=${sbxEnvironment.COPILOT_PROVIDER_BASE_URL || '(unset)'}`); + logger.info(`[sbx-env] COPILOT_GITHUB_TOKEN=${redactSecret(sbxEnvironment.COPILOT_GITHUB_TOKEN)}`); + logger.info(`[sbx-env] COPILOT_API_KEY=${redactSecret(sbxEnvironment.COPILOT_API_KEY)}`); + logger.info(`[sbx-env] HTTPS_PROXY=${sbxEnvironment.HTTPS_PROXY || '(unset)'}`); + logger.info(`[sbx-env] COPILOT_PROVIDER_API_KEY=${redactSecret(sbxEnvironment.COPILOT_PROVIDER_API_KEY)}`); + + // Create the sandbox with configured mounts, proxy chaining through Squid + const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); + sbxName = await createSandbox({ + workspaceDir, + squidIp: SQUID_IP, + extraMounts: config.volumeMounts, + }); + + // Wait for api-proxy to be healthy before launching agent. + // In Docker mode, depends_on: service_healthy gates this; for sbx we poll + // via host.docker.internal which resolves to the docker0 bridge from the VM. + if (config.enableApiProxy) { + logger.info('[sbx] Polling api-proxy health via host.docker.internal...'); + const healthCmd = [ + 'for i in $(seq 1 30); do', + ` if curl -sf --max-time 2 http://${SBX_HOST_DOCKER_INTERNAL}:10000/health >/dev/null 2>&1; then`, + ' echo "api-proxy healthy after ${i}s"; exit 0;', + ' fi;', + ' sleep 1;', + 'done;', + 'echo "api-proxy health timeout"; exit 1', + ].join(' '); + + const healthResult = await execInSandbox(sbxName, healthCmd, { + timeoutMinutes: 1, + workDir: config.containerWorkDir, + environment: sbxEnvironment, + }); + if (healthResult.exitCode !== 0) { + logger.warn('[sbx] api-proxy health check failed — proceeding anyway'); + } + } + + // Verify squid proxy is reachable from sandbox + logger.info('[sbx-diag] Verifying squid proxy connectivity...'); + const diagCmd = [ + `echo -n "squid ${SBX_GATEWAY_IP}:3128 → "`, + `curl -sS --max-time 5 --proxy "http://${SBX_GATEWAY_IP}:3128" -o /dev/null -w "%{http_code}" https://api.github.com/ 2>&1`, + 'echo ""', + ].join(' && '); + + const diagResult = await execInSandbox(sbxName, diagCmd, { + timeoutMinutes: 1, + workDir: config.containerWorkDir, + environment: sbxEnvironment, + }); + logger.info(`[sbx-diag] Connectivity check exited with code ${diagResult.exitCode}`); + } + : startContainers; + + const sbxRunAgentCommand = useSbx + ? async (_workDir: string, _allowedDomains: string[], _proxyLogsDir?: string, agentTimeoutMinutes?: number) => { + if (!sbxName) throw new Error('Sandbox not created'); + logger.info(`[sbx] Launching agent command in sandbox "${sbxName}" (timeout: ${agentTimeoutMinutes ?? 'none'} min)`); + logger.debug(`[sbx] Agent command: ${config.agentCommand.substring(0, 200)}...`); + const result = await execInSandbox(sbxName, config.agentCommand, { + timeoutMinutes: agentTimeoutMinutes, + workDir: config.containerWorkDir, + environment: sbxEnvironment, + tty: config.tty, + }); + logger.info(`[sbx] Agent command exited with code ${result.exitCode}`); + + // Dump api-proxy logs for debugging connection issues + if (config.enableApiProxy && result.exitCode !== 0) { + try { + const { execSync } = await import('child_process'); + const proxyLogs = execSync('docker logs --tail 80 awf-api-proxy 2>&1', { encoding: 'utf-8', timeout: 10000 }); + logger.info(`[sbx-diag] api-proxy logs:\n${proxyLogs}`); + const healthStatus = execSync('docker inspect --format={{.State.Health.Status}} awf-api-proxy 2>&1', { encoding: 'utf-8', timeout: 5000 }); + logger.info(`[sbx-diag] api-proxy health status: ${healthStatus.trim()}`); + } catch { /* ignore diagnostic failures */ } + } + + return { exitCode: result.exitCode, blockedDomains: [] as string[] }; + } + : runAgentCommand; + exitCode = await runMainWorkflow( config, { ensureFirewallNetwork, setupHostIptables, writeConfigs, - startContainers, - runAgentCommand, + startContainers: sbxStartContainers, + runAgentCommand: sbxRunAgentCommand, collectDiagnosticLogs, assertTopologySupported, connectTopologyContainers, diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index bb89048f5..42a921c6b 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -321,6 +321,66 @@ describe('generateDockerCompose', () => { }); }); + describe('microVM runtime (sbx)', () => { + it('omits compose agent and agent-only helper services', () => { + const config = { + ...mockConfig, + containerRuntime: 'sbx', + runnerTopology: 'arc-dind' as const, + networkIsolation: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent).toBeUndefined(); + expect(result.services['iptables-init']).toBeUndefined(); + expect(result.services['sysroot-stage']).toBeUndefined(); + expect(result.volumes?.sysroot).toBeUndefined(); + }); + + it('publishes api-proxy ports when api-proxy is enabled', () => { + const config = { + ...mockConfig, + containerRuntime: 'sbx', + runnerTopology: 'arc-dind' as const, + networkIsolation: false, + enableApiProxy: true, + }; + const networkWithProxy = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }; + const result = generateDockerCompose(config, networkWithProxy); + + expect(result.services['api-proxy']).toBeDefined(); + const ports = result.services['api-proxy'].ports; + expect(ports).toContain('10000:10000'); + expect(ports).toContain('10001:10001'); + expect(ports).toContain('10002:10002'); + expect(ports).toContain('10003:10003'); + expect(ports).toContain('10004:10004'); + }); + + it('attaches api-proxy to awf-ext in network-isolation mode for port publishing', () => { + const config = { + ...mockConfig, + containerRuntime: 'sbx', + runnerTopology: 'arc-dind' as const, + networkIsolation: true, + enableApiProxy: true, + }; + const networkWithProxy = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }; + const result = generateDockerCompose(config, networkWithProxy); + + expect(result.services['api-proxy']).toBeDefined(); + const networks = result.services['api-proxy'].networks as Record; + expect(networks['awf-ext']).toBeDefined(); + expect(result.services['api-proxy'].ports).toContain('10002:10002'); + }); + }); + describe('host-gateway IP passthrough (AWF_HOST_GATEWAY_IP)', () => { afterEach(() => { mockResolveDockerHostGateway.mockReset(); @@ -558,4 +618,3 @@ describe('generateDockerCompose', () => { }); }); }); - diff --git a/src/compose-generator.ts b/src/compose-generator.ts index bbd956cbd..53ae3c6ae 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -10,6 +10,8 @@ import { buildSquidService } from './services/squid-service'; import { buildAgentEnvironment, buildAgentVolumes, buildAgentService } from './services/agent-service'; import { assembleOptionalServices } from './services/optional-services'; import { buildComposeNetworks } from './compose-network'; +import { runtimeUsesComposeAgent } from './container-runtime'; +import { API_PROXY_PORTS } from './types/ports'; /** * Generates Docker Compose configuration @@ -104,10 +106,15 @@ export function generateDockerCompose( }); // ── Assemble base services ───────────────────────────────────────────────── + // For microVM backends (e.g. sbx), the agent is NOT a compose service — + // it's launched externally. We still build the agent service object so that + // optional-services can wire depends_on edges for infra containers, but we + // omit it from the final compose output. + const includeAgent = runtimeUsesComposeAgent(config.containerRuntime); const services: Record = { 'squid-proxy': squidService, - 'agent': agentService, + ...(includeAgent ? { 'agent': agentService } : {}), }; // ── Insert optional sidecars and wire depends_on edges ──────────────────── @@ -117,6 +124,7 @@ export function generateDockerCompose( agentService, agentVolumes, environment, + includeComposeAgent: includeAgent, config, networkConfig, imageConfig, @@ -125,6 +133,31 @@ export function generateDockerCompose( effectiveHome, }); + // ── Publish infra ports for microVM runtimes ─────────────────────────────── + // When the agent runs in a microVM (e.g. sbx), it can't reach Docker-internal + // IPs (172.30.0.x). Publish api-proxy ports to the host so the microVM can + // reach them via its gateway IP. (Squid already has ports published.) + // + // In network-isolation mode the internal network blocks host→container traffic, + // so we also attach api-proxy to the external bridge (`awf-ext`) — same as + // Squid — so published ports are reachable from outside Docker. + if (!includeAgent && services['api-proxy']) { + const proxyService = services['api-proxy']; + if (!proxyService.ports) { + proxyService.ports = []; + } + for (const port of Object.values(API_PROXY_PORTS)) { + proxyService.ports.push(`${port}:${port}`); + } + // Attach to external network so port publishing works with internal awf-net + if (config.networkIsolation) { + proxyService.networks = { + ...(proxyService.networks || {}), + 'awf-ext': {}, + }; + } + } + // ── Assemble and return the compose result ───────────────────────────────── return buildComposeNetworks({ diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 4fa1e99e9..0246847e1 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -1,4 +1,5 @@ import { resolveDockerRuntime, getRuntimeCapabilities, runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runtime'; +import { sanitizeEnvForSbx } from './sbx-manager'; describe('container-runtime', () => { describe('resolveDockerRuntime', () => { @@ -6,6 +7,10 @@ describe('container-runtime', () => { expect(resolveDockerRuntime('gvisor')).toBe('runsc'); }); + it('returns undefined for sbx (no OCI runtime)', () => { + expect(resolveDockerRuntime('sbx')).toBeUndefined(); + }); + it('passes through unknown runtime names unchanged', () => { expect(resolveDockerRuntime('kata')).toBe('kata'); expect(resolveDockerRuntime('runsc')).toBe('runsc'); @@ -14,7 +19,7 @@ describe('container-runtime', () => { }); describe('getRuntimeCapabilities', () => { - it('returns capabilities for known runtimes', () => { + it('returns capabilities for gvisor', () => { const caps = getRuntimeCapabilities('gvisor'); expect(caps).toBeDefined(); expect(caps!.dockerRuntime).toBe('runsc'); @@ -22,6 +27,14 @@ describe('container-runtime', () => { expect(caps!.executionModel).toBe('compose'); }); + it('returns capabilities for sbx', () => { + const caps = getRuntimeCapabilities('sbx'); + expect(caps).toBeDefined(); + expect(caps!.dockerRuntime).toBeUndefined(); + expect(caps!.needsStaticDns).toBe(false); + expect(caps!.executionModel).toBe('microvm'); + }); + it('returns undefined for unknown runtimes', () => { expect(getRuntimeCapabilities('kata')).toBeUndefined(); expect(getRuntimeCapabilities('runsc')).toBeUndefined(); @@ -33,6 +46,10 @@ describe('container-runtime', () => { expect(runtimeNeedsStaticDns('gvisor')).toBe(true); }); + it('returns false for sbx', () => { + expect(runtimeNeedsStaticDns('sbx')).toBe(false); + }); + it('returns false for unknown runtimes', () => { expect(runtimeNeedsStaticDns('kata')).toBe(false); expect(runtimeNeedsStaticDns('runsc')).toBe(false); @@ -53,9 +70,58 @@ describe('container-runtime', () => { expect(runtimeUsesComposeAgent('gvisor')).toBe(true); }); + it('returns false for microvm-model runtimes (sbx)', () => { + expect(runtimeUsesComposeAgent('sbx')).toBe(false); + }); + it('returns true for unknown runtimes (assumed compose)', () => { expect(runtimeUsesComposeAgent('kata')).toBe(true); expect(runtimeUsesComposeAgent('runsc')).toBe(true); }); }); }); + +describe('sanitizeEnvForSbx', () => { + const origEnv = { ...process.env }; + + afterEach(() => { + // Restore process.env + for (const key of Object.keys(process.env)) { + if (!(key in origEnv)) delete process.env[key]; + } + Object.assign(process.env, origEnv); + }); + + it('strips env vars matching secret patterns', () => { + process.env.COPILOT_GITHUB_TOKEN = 'ghp_secret123'; + process.env.GH_AW_GITHUB_TOKEN = 'ghp_secret456'; + process.env.GITHUB_MCP_SERVER_TOKEN = 'ghp_secret789'; + process.env.DOCKER_PAT = 'dkr_pat_abc'; + process.env.DOCKER_USERNAME = 'myuser'; + process.env.MY_API_KEY = 'key123'; + process.env.AWS_SECRET_ACCESS_KEY = 'awskey'; + process.env.SAFE_VARIABLE = 'keep-this'; + + const result = sanitizeEnvForSbx(); + + expect(result.COPILOT_GITHUB_TOKEN).toBeUndefined(); + expect(result.GH_AW_GITHUB_TOKEN).toBeUndefined(); + expect(result.GITHUB_MCP_SERVER_TOKEN).toBeUndefined(); + expect(result.DOCKER_PAT).toBeUndefined(); + expect(result.DOCKER_USERNAME).toBeUndefined(); + expect(result.MY_API_KEY).toBeUndefined(); + expect(result.AWS_SECRET_ACCESS_KEY).toBeUndefined(); + expect(result.SAFE_VARIABLE).toBe('keep-this'); + }); + + it('allows overrides to pass through', () => { + const result = sanitizeEnvForSbx({ DOCKER_SANDBOXES_PROXY: 'http://172.30.0.10:3128' }); + expect(result.DOCKER_SANDBOXES_PROXY).toBe('http://172.30.0.10:3128'); + }); + + it('preserves PATH and HOME', () => { + const result = sanitizeEnvForSbx(); + expect(result.PATH).toBeDefined(); + expect(result.HOME).toBeDefined(); + }); +}); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 358f1555c..e9b13354c 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -88,11 +88,11 @@ const RUNTIME_REGISTRY: Readonly> = { needsStaticDns: true, }, // Future: Docker sbx microVM backend - // sbx: { - // executionModel: 'microvm', - // dockerRuntime: undefined, - // needsStaticDns: false, // sbx manages its own DNS - // }, + sbx: { + executionModel: 'microvm', + dockerRuntime: undefined, + needsStaticDns: false, // sbx manages its own DNS + }, }; // ─── Public API ────────────────────────────────────────────────────────────── diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts new file mode 100644 index 000000000..379501d9c --- /dev/null +++ b/src/sbx-manager.test.ts @@ -0,0 +1,62 @@ +import { createSandbox, removeSandbox } from './sbx-manager'; +import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; +import { logger } from './logger'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory()); +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('./logger', () => require('./test-helpers/mock-logger.test-utils').loggerMockFactory()); + +const mockedLogger = jest.mocked(logger); + +describe('sbx-manager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createSandbox', () => { + it('uses shell agent, configured mounts, and sanitized env', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // auth check + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); // sbx create + + await createSandbox({ + name: 'awf-agent-test', + workspaceDir: '/workspace', + squidIp: '172.30.0.10', + extraMounts: ['/tmp/gh-aw:/tmp/gh-aw:ro'], + }); + + expect(mockExecaFn).toHaveBeenCalledWith('sbx', [ + 'create', + '--name', 'awf-agent-test', + 'shell', + '/workspace', + '/tmp/gh-aw:ro', + '/tmp', + '/usr/local/bin', + process.env.HOME || '/home/runner', + ], expect.objectContaining({ + input: 'y\n', + env: expect.not.objectContaining({ + XDG_CONFIG_HOME: expect.anything(), + DOCKER_SANDBOXES_PROXY: expect.anything(), + }), + })); + }); + }); + + describe('removeSandbox', () => { + it('warns when sbx rm exits non-zero', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // stop + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'still running' }); // rm + + await removeSandbox('awf-agent-test'); + + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove sandbox "awf-agent-test"'), + ); + }); + }); +}); diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts new file mode 100644 index 000000000..cc129380e --- /dev/null +++ b/src/sbx-manager.ts @@ -0,0 +1,289 @@ +/** + * Docker sbx (sandbox) microVM lifecycle manager. + * + * Manages the agent process inside a Docker sbx microVM while AWF's + * infrastructure containers (Squid, api-proxy) remain in Docker Compose + * on the host. All sbx egress is chained through AWF's Squid proxy via + * the `DOCKER_SANDBOXES_PROXY` environment variable. + * + * ## Lifecycle + * + * 1. `createSandbox()` — `sbx create` with workspace mounts + * 2. `execInSandbox()` — `sbx exec` to run the agent command, streams + * stdout/stderr and collects exit code + * 3. `removeSandbox()` — `sbx stop` + `sbx rm` for cleanup + * + * ## Proxy chaining + * + * `DOCKER_SANDBOXES_PROXY` is a daemon-level env var that routes all + * sandbox egress through the specified proxy. In CI (one sandbox per + * runner), this is safe to set globally. AWF sets it to Squid's address + * (`http://:3128`) before creating the sandbox, so all agent + * traffic flows through AWF's domain ACL. + */ + +import execa from 'execa'; +import { logger } from './logger'; + +/** Name prefix for AWF-managed sandboxes. */ +const SBX_NAME_PREFIX = 'awf-agent'; + +/** + * Env vars that must NEVER reach the sbx CLI or sandbox interior. + * Patterns are matched case-insensitively against env var names. + */ +const SECRET_ENV_PATTERNS = [ + /TOKEN/i, + /SECRET/i, + /PASSWORD/i, + /KEY/i, + /CREDENTIAL/i, + /PAT$/i, + /^DOCKER_PAT$/i, + /^DOCKER_USERNAME$/i, +]; + +/** Default sandbox name (single-sandbox-per-run model). */ +export const SBX_DEFAULT_NAME = `${SBX_NAME_PREFIX}-${process.pid}`; + +export interface SbxConfig { + /** Sandbox name (defaults to `awf-agent-`). */ + name?: string; + /** Workspace directory to mount into the sandbox. */ + workspaceDir: string; + /** Squid proxy IP for DOCKER_SANDBOXES_PROXY. */ + squidIp: string; + /** Squid proxy port (default 3128). */ + squidPort?: number; + /** Additional workspace mounts (read-only paths). */ + extraMounts?: string[]; +} + +export interface SbxExecOptions { + timeoutMinutes?: number; + workDir?: string; + environment?: Record; + tty?: boolean; +} + +/** + * Strips secret-bearing env vars from process.env so they never reach + * the sbx CLI or the sandbox interior. Returns a shallow copy with + * only non-secret entries plus any explicit overrides. + */ +export function sanitizeEnvForSbx( + overrides: Record = {}, +): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!SECRET_ENV_PATTERNS.some((p) => p.test(key))) { + clean[key] = value; + } + } + return { ...clean, ...overrides }; +} + +/** + * Creates a Docker sbx sandbox with workspace mounts. + * Sets `DOCKER_SANDBOXES_PROXY` to chain all egress through AWF's Squid. + */ +export async function createSandbox(config: SbxConfig): Promise { + const name = config.name || SBX_DEFAULT_NAME; + const squidPort = config.squidPort || 3128; + const proxyUrl = `http://${config.squidIp}:${squidPort}`; + + logger.info(`[sbx] Creating sandbox "${name}" (proxy ${proxyUrl} will be set at exec time)`); + + // Verify daemon is running and authenticated before attempting create + // (sbx has no 'auth status' command; 'sbx ls' requires auth so we use it as a probe) + const authCheck = await execa('sbx', ['ls'], { + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + timeout: 10_000, + }); + if ((authCheck.exitCode ?? 1) !== 0) { + const daemonCheck = await execa('sbx', ['daemon', 'status'], { + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + timeout: 10_000, + }); + logger.error(`[sbx] Not authenticated. daemon status: ${(daemonCheck.stdout || '').trim()}`); + throw new Error( + `sbx is not authenticated (sbx ls exit=${authCheck.exitCode}). ` + + `Ensure 'sbx login' was called with a running daemon. ` + + `Daemon: ${(daemonCheck.stdout || '').trim()}. ` + + `Error: ${(authCheck.stderr || '').trim()}` + ); + } + logger.info('[sbx] Auth verified ✓'); + + const args = [ + 'create', + '--name', name, + 'shell', // shell agent provides a generic sandbox + config.workspaceDir, + ]; + + // Add extra mounts passed from AWF config. + // AWF uses Docker-style "host:container:mode" format but sbx uses positional + // paths with optional :ro suffix (host path = container path in microVM). + const seenPaths = new Set([config.workspaceDir]); + if (config.extraMounts) { + for (const mount of config.extraMounts) { + const parts = mount.split(':'); + const hostPath = parts[0]; + if (seenPaths.has(hostPath)) continue; // deduplicate + seenPaths.add(hostPath); + // Determine mode: last segment is 'ro' or 'rw' if there are 2+ colons + const mode = parts.length >= 3 ? parts[parts.length - 1] : (parts.length === 2 && (parts[1] === 'ro' || parts[1] === 'rw') ? parts[1] : undefined); + if (mode === 'ro') { + args.push(`${hostPath}:ro`); + } else { + args.push(hostPath); + } + } + } + + // Mount /tmp so agent runtime files (prompts, logs) are accessible. + // Mount /usr/local/bin for Copilot CLI and other installed tools. + // Mount $HOME for agent writable dirs (.cache, .config, .local, etc.) + const homePath = process.env.HOME || '/home/runner'; + for (const sysPath of ['/tmp', '/usr/local/bin', homePath]) { + if (!seenPaths.has(sysPath)) { + seenPaths.add(sysPath); + args.push(sysPath); + } + } + + logger.info(`[sbx] Running: sbx ${args.join(' ')}`); + + const env = sanitizeEnvForSbx(); + delete env.XDG_CONFIG_HOME; + delete env.DOCKER_SANDBOXES_PROXY; + + const createResult = await execa('sbx', args, { + env, + input: 'y\n', + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + timeout: 120_000, // 2 minute timeout for sandbox creation + }); + + const stdout = (createResult.stdout || '').trim(); + const stderr = (createResult.stderr || '').trim(); + const sbxSucceeded = stdout.includes('Created sandbox'); + const exitCode = createResult.exitCode ?? 1; + + if (exitCode !== 0 && !sbxSucceeded) { + // Log full debug output for diagnostics + if (stdout) logger.info(`[sbx] create stdout: ${stdout.substring(0, 2000)}`); + if (stderr) logger.info(`[sbx] create stderr: ${stderr.substring(0, 2000)}`); + throw new Error( + `sbx create failed (exit ${exitCode}): ${stderr || stdout || 'unknown error'}` + ); + } + + logger.info(`[sbx] Sandbox "${name}" created (exit=${exitCode}, detected=${sbxSucceeded}). stdout=${stdout.substring(0, 200)}`); + return name; +} + +/** + * Executes a command inside the sandbox, streaming stdout/stderr. + * Returns the exit code of the command. + */ +export async function execInSandbox( + name: string, + command: string, + options?: SbxExecOptions, +): Promise<{ exitCode: number }> { + logger.info(`Executing in sandbox "${name}": ${command}`); + + const args = ['exec']; + if (options?.workDir) { + args.push('--workdir', options.workDir); + } + if (options?.tty) { + args.push('--tty'); + } + if (options?.environment) { + for (const [key, value] of Object.entries(options.environment)) { + args.push('--env', `${key}=${value}`); + } + } + + args.push(name, 'bash', '-lc', command); + + try { + const result = await execa('sbx', args, { + env: sanitizeEnvForSbx(), + stdio: ['ignore', 'inherit', 'inherit'], + reject: false, + timeout: options?.timeoutMinutes ? options.timeoutMinutes * 60 * 1000 : undefined, + }); + + const exitCode = result.exitCode ?? 1; + + if (exitCode === 0) { + logger.info(`Sandbox command completed successfully`); + } else { + logger.warn(`Sandbox command exited with code ${exitCode}`); + } + + return { exitCode }; + } catch (error: any) { + if (error.timedOut) { + logger.error(`Sandbox command timed out after ${options?.timeoutMinutes} minutes`); + return { exitCode: 124 }; // match timeout convention + } + logger.error(`Sandbox exec failed: ${error.message}`); + return { exitCode: 1 }; + } +} + +/** + * Stops and removes the sandbox. + */ +export async function removeSandbox(name: string): Promise { + logger.info(`Removing sandbox "${name}"...`); + + try { + const stopResult = await execa('sbx', ['stop', name], { + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + }); + if ((stopResult.exitCode ?? 1) !== 0) { + const stderr = stopResult.stderr?.trim(); + logger.warn( + `Failed to stop sandbox "${name}" (exit ${(stopResult.exitCode ?? 1)}${stderr ? `: ${stderr}` : ''})` + ); + } + } catch { + // stop may fail if already stopped — that's fine + } + + const rmResult = await execa('sbx', ['rm', '--force', name], { + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + }); + if ((rmResult.exitCode ?? 1) !== 0) { + const stderr = rmResult.stderr?.trim(); + logger.warn( + `Failed to remove sandbox "${name}" (exit ${(rmResult.exitCode ?? 1)}${stderr ? `: ${stderr}` : ''})` + ); + return; + } + + logger.info(`Sandbox "${name}" removed`); +} + +/** + * Checks if the sbx CLI is available on the system. + */ +export async function isSbxAvailable(): Promise { + try { + await execa('sbx', ['version'], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index cbd49eb83..b922bcfc6 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -14,6 +14,7 @@ interface AssembleOptionalServicesParams { agentService: any; agentVolumes: string[]; environment: Record; + includeComposeAgent?: boolean; config: WrapperConfig; networkConfig: NetworkConfig; imageConfig: ImageBuildConfig; @@ -246,16 +247,21 @@ export function assembleOptionalServices( const { agentVolumes, environment, config, networkConfig, imageConfig } = params; const networkIsolation = !!config.networkIsolation; + const includeComposeAgent = params.includeComposeAgent !== false; const sysrootActive = isSysrootEnabled(config); presetSidecarIpEnvVars(environment, config, networkConfig); - assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); - assembleIptablesInitService(params, networkIsolation); + if (includeComposeAgent) { + assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); + assembleIptablesInitService(params, networkIsolation); + } assembleApiProxyService(params); assembleDohProxyService(params); assembleCliProxyService(params); - const namedVolumes = finalizeSysrootVolumes(agentVolumes, sysrootActive); + const namedVolumes = includeComposeAgent + ? finalizeSysrootVolumes(agentVolumes, sysrootActive) + : undefined; return { namedVolumes }; }