diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 695f63c89..c4df9e8af 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -349,15 +349,12 @@ jobs:
--type spdxjson \
ghcr.io/${{ github.repository }}/cli-proxy@${{ steps.build_cli_proxy.outputs.digest }}
- # Build the minimal query sandbox and trusted broker from separate Dockerfile
- # targets. The runtime pulls both before the offline broker starts.
- build-bounded-query:
- name: Build Bounded Query Image
+ # Build the unified enclave images from containers/enclave/Dockerfile.
+ build-enclaves:
+ name: Build Enclave Images
runs-on: ubuntu-latest
needs: bump-version
outputs:
- query_digest: ${{ steps.build_bounded_query.outputs.digest }}
- broker_digest: ${{ steps.build_bounded_query_broker.outputs.digest }}
enclave_script_digest: ${{ steps.build_enclave_script.outputs.digest }}
enclave_agent_digest: ${{ steps.build_enclave_agent.outputs.digest }}
enclave_mcp_server_digest: ${{ steps.build_enclave_mcp_server.outputs.digest }}
@@ -385,78 +382,13 @@ jobs:
- name: Install cosign
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
- - name: Build and push Bounded Query image
- id: build_bounded_query
- uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
- with:
- context: ./containers/bounded-query
- target: query
- push: true
- platforms: linux/amd64,linux/arm64
- tags: |
- ghcr.io/${{ github.repository }}/bounded-query:${{ needs.bump-version.outputs.version_number }}
- ghcr.io/${{ github.repository }}/bounded-query:latest
- cache-from: type=gha,scope=bounded-query
- cache-to: type=gha,mode=max,scope=bounded-query
-
- - name: Sign Bounded Query image with cosign
- run: |
- cosign sign --yes \
- ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }}
-
- - name: Generate SBOM for Bounded Query image
- uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
- with:
- image: ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }}
- format: spdx-json
- output-file: bounded-query-sbom.spdx.json
-
- - name: Attest SBOM for Bounded Query image
- run: |
- cosign attest --yes \
- --predicate bounded-query-sbom.spdx.json \
- --type spdxjson \
- ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }}
-
- - name: Build and push Bounded Query Broker image
- id: build_bounded_query_broker
- uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
- with:
- context: ./containers/bounded-query
- target: broker
- push: true
- platforms: linux/amd64,linux/arm64
- tags: |
- ghcr.io/${{ github.repository }}/bounded-query-broker:${{ needs.bump-version.outputs.version_number }}
- ghcr.io/${{ github.repository }}/bounded-query-broker:latest
- cache-from: type=gha,scope=bounded-query-broker
- cache-to: type=gha,mode=max,scope=bounded-query-broker
-
- - name: Sign Bounded Query Broker image with cosign
- run: |
- cosign sign --yes \
- ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }}
-
- - name: Generate SBOM for Bounded Query Broker image
- uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
- with:
- image: ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }}
- format: spdx-json
- output-file: bounded-query-broker-sbom.spdx.json
-
- - name: Attest SBOM for Bounded Query Broker image
- run: |
- cosign attest --yes \
- --predicate bounded-query-broker-sbom.spdx.json \
- --type spdxjson \
- ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }}
-
- name: Build and push Enclave Script image
id: build_enclave_script
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
- context: ./containers/bounded-query
- target: query
+ context: ./containers
+ file: ./containers/enclave/Dockerfile
+ target: enclave-script
push: true
platforms: linux/amd64,linux/arm64
tags: |
@@ -488,11 +420,9 @@ jobs:
id: build_enclave_agent
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
- # The unified enclave agent executor reuses the audited native
- # bounded-agent enclave target verbatim, published under its own name.
context: ./containers
- file: ./containers/bounded-agent/Dockerfile
- target: enclave
+ file: ./containers/enclave/Dockerfile
+ target: enclave-agent
push: true
platforms: linux/amd64,linux/arm64
tags: |
@@ -524,10 +454,8 @@ jobs:
id: build_enclave_mcp_server
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
- # The server drives both enclave executors, so its context spans
- # containers/bounded-query and containers/bounded-agent.
context: ./containers
- file: ./containers/bounded-query/enclave-mcp/Dockerfile
+ file: ./containers/enclave/Dockerfile
target: enclave-mcp-server
push: true
platforms: linux/amd64,linux/arm64
@@ -556,110 +484,6 @@ jobs:
--type spdxjson \
ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ steps.build_enclave_mcp_server.outputs.digest }}
- # Build the native Copilot bounded-agent enclave and its trusted broker from separate
- # Dockerfile targets. The build context is ./containers (not
- # ./containers/bounded-agent) because the broker reuses the shared
- # bounded-execution foundation and sandbox seccomp profile that live under
- # containers/bounded-query.
- build-bounded-agent:
- name: Build Bounded Agent Image
- runs-on: ubuntu-latest
- needs: bump-version
- outputs:
- enclave_digest: ${{ steps.build_bounded_agent.outputs.digest }}
- broker_digest: ${{ steps.build_bounded_agent_broker.outputs.digest }}
- steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
- with:
- ref: ${{ needs.bump-version.outputs.version }}
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0
- with:
- platforms: arm64
-
- - name: Install cosign
- uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
-
- - name: Build and push Bounded Agent image
- id: build_bounded_agent
- uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
- with:
- context: ./containers
- file: ./containers/bounded-agent/Dockerfile
- target: enclave
- push: true
- platforms: linux/amd64,linux/arm64
- tags: |
- ghcr.io/${{ github.repository }}/bounded-agent:${{ needs.bump-version.outputs.version_number }}
- ghcr.io/${{ github.repository }}/bounded-agent:latest
- cache-from: type=gha,scope=bounded-agent
- cache-to: type=gha,mode=max,scope=bounded-agent
-
- - name: Sign Bounded Agent image with cosign
- run: |
- cosign sign --yes \
- ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }}
-
- - name: Generate SBOM for Bounded Agent image
- uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
- with:
- image: ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }}
- format: spdx-json
- output-file: bounded-agent-sbom.spdx.json
-
- - name: Attest SBOM for Bounded Agent image
- run: |
- cosign attest --yes \
- --predicate bounded-agent-sbom.spdx.json \
- --type spdxjson \
- ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }}
-
- - name: Build and push Bounded Agent Broker image
- id: build_bounded_agent_broker
- uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
- with:
- context: ./containers
- file: ./containers/bounded-agent/Dockerfile
- target: broker
- push: true
- platforms: linux/amd64,linux/arm64
- tags: |
- ghcr.io/${{ github.repository }}/bounded-agent-broker:${{ needs.bump-version.outputs.version_number }}
- ghcr.io/${{ github.repository }}/bounded-agent-broker:latest
- cache-from: type=gha,scope=bounded-agent-broker
- cache-to: type=gha,mode=max,scope=bounded-agent-broker
-
- - name: Sign Bounded Agent Broker image with cosign
- run: |
- cosign sign --yes \
- ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }}
-
- - name: Generate SBOM for Bounded Agent Broker image
- uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
- with:
- image: ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }}
- format: spdx-json
- output-file: bounded-agent-broker-sbom.spdx.json
-
- - name: Attest SBOM for Bounded Agent Broker image
- run: |
- cosign attest --yes \
- --predicate bounded-agent-broker-sbom.spdx.json \
- --type spdxjson \
- ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }}
-
# Build agent-act image with catthehacker/ubuntu:act-24.04 base for GitHub Actions parity
# amd64-only: catthehacker/ubuntu:act-24.04 does not publish arm64 manifests
build-agent-act:
@@ -896,7 +720,7 @@ jobs:
release:
name: Create Release
runs-on: ubuntu-latest
- needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-bounded-query, build-bounded-agent, build-gh-aw-node]
+ needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
@@ -996,13 +820,9 @@ jobs:
"ghcr.io/${{ github.repository }}/agent-act@${{ needs['build-agent-act'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/api-proxy@${{ needs['build-api-proxy'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \
- "ghcr.io/${{ github.repository }}/bounded-query@${{ needs['build-bounded-query'].outputs.query_digest }}" \
- "ghcr.io/${{ github.repository }}/bounded-query-broker@${{ needs['build-bounded-query'].outputs.broker_digest }}" \
- "ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-bounded-query'].outputs.enclave_script_digest }}" \
- "ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-bounded-query'].outputs.enclave_agent_digest }}" \
- "ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ needs['build-bounded-query'].outputs.enclave_mcp_server_digest }}" \
- "ghcr.io/${{ github.repository }}/bounded-agent@${{ needs['build-bounded-agent'].outputs.enclave_digest }}" \
- "ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ needs['build-bounded-agent'].outputs.broker_digest }}" \
+ "ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-enclaves'].outputs.enclave_script_digest }}" \
+ "ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-enclaves'].outputs.enclave_agent_digest }}" \
+ "ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ needs['build-enclaves'].outputs.enclave_mcp_server_digest }}" \
"ghcr.io/${{ github.repository }}/gh-aw-node@${{ needs['build-gh-aw-node'].outputs.digest }}" \
> release/containers.txt
echo "Generated containers.txt:"
diff --git a/.github/workflows/smoke-bounded-agents-gvisor.lock.yml b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml
deleted file mode 100644
index a73c13146..000000000
--- a/.github/workflows/smoke-bounded-agents-gvisor.lock.yml
+++ /dev/null
@@ -1,1392 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"47ed96d329ca5ac86b7c0b0df9307202bc3af5489e215ace2bd69084a3a9c701","body_hash":"245ddc5ea0c938d471a36a6fd125e26f7b84f455eeb2706f3d9fa8a4a3c5ee16","compiler_version":"v0.86.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}}
-# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"19356acbcf6b0677aa06bacc1b9894fe883ae751","version":"v0.86.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"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.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]}
-# This file was automatically generated by gh-aw (v0.86.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# End-to-end smoke test for finite-schema gVisor bounded-agent enclaves
-#
-# Frontmatter env variables:
-# - GH_TOKEN: (main workflow)
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
-#
-# Container images used:
-# -
-# -
-# -
-# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8
-# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b
-# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
-
-name: "Smoke Bounded Agents gVisor"
-on:
- schedule:
- - cron: "41 */12 * * *" # Friendly format: every 12h (scattered)
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- cancel-in-progress: false
- group: smoke-bounded-agents-gvisor
-
-run-name: "Smoke Bounded Agents gVisor"
-
-env:
- GH_TOKEN: ${{ github.token }}
-
-jobs:
- activation:
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
- daily_ai_credits_guardrail_status: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }}
- daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
- daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AGENT_VERSION: "1.0.34"
- GH_AW_INFO_CLI_VERSION: "v0.86.0"
- GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Enforce strict mode policy
- if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }}
- run: |
- echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true."
- exit 1
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Restore daily AIC usage cache (artifact fallback)
- id: restore-daily-aic-cache-fallback
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
- GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
- await main();
- - name: Check daily workflow token guardrail
- id: daily-effective-workflow-guardrail
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
- GH_AW_HAS_SLASH_COMMAND: "false"
- GH_AW_HAS_LABEL_COMMAND: "false"
- GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
- await main();
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "smoke-bounded-agents-gvisor.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.86.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF'
-
- GH_AW_PROMPT_2cf238e256fdffdc_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_2cf238e256fdffdc_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md"
- cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF'
-
- GH_AW_PROMPT_2cf238e256fdffdc_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_2cf238e256fdffdc_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF'
-
- {{#runtime-import .github/workflows/smoke-bounded-agents-gvisor.md}}
- GH_AW_PROMPT_2cf238e256fdffdc_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs: activation
- if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagentsgvisor
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Build unreleased AWF
- run: |-
- npm ci
- npm run build
-
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34
- env:
- GH_HOST: github.com
- - 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 < \"$HOME/.local/bin/configure-bounded-agent.cjs\" <<'NODE'\nconst fs = require(\"fs\");\nconst [file, runtime] = process.argv.slice(2);\nif (!file || !runtime) {\n throw new Error(\"usage: configure-bounded-agent.cjs \");\n}\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { copilot: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime,\n engine: \"copilot\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n timeout: 540,\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE\ncat > \"$HOME/.local/bin/awf\" <<'SH'\n#!/bin/bash\nset -euo pipefail\nconfig_path=\"\"\nargs=(\"$@\")\nfor ((index = 0; index < ${#args[@]}; index++)); do\n if [[ \"${args[$index]}\" == \"--config\" && $((index + 1)) -lt ${#args[@]} ]]; then\n config_path=\"${args[$((index + 1))]}\"\n break\n fi\ndone\nif [[ -z \"$config_path\" ]]; then\n echo \"bounded-agent smoke wrapper requires --config\" >&2\n exit 2\nfi\nnode \"$HOME/.local/bin/configure-bounded-agent.cjs\" \"$config_path\" gvisor\nexec node \"$GITHUB_WORKSPACE/dist/cli.js\" \"$@\"\nSH\nchmod +x \"$HOME/.local/bin/awf\""
-
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_a8afd7e7f027a241_EOF'
- {"create_issue":{"labels":["smoke-bounded-agents-gvisor"],"max":1,"title_prefix":"[smoke-bounded-agents-gvisor]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_a8afd7e7f027a241_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents-gvisor]\". Labels [\"smoke-bounded-agents-gvisor\"] will be automatically added."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20
- },
- "fields": {
- "type": "array"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # 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"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- 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 GH_AW_SINK_VISIBILITY -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.4.8'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.8.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- 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_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 30
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)"
- if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then
- echo "GitHub Copilot CLI executable not found on PATH after installation" >&2
- exit 127
- fi
- GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot"
- mkdir -p "${RUNNER_TEMP}/gh-aw/bin"
- if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then
- cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN"
- fi
- chmod 755 "$GH_AW_COPILOT_BIN"
-
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- 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 ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --exclude-env OPENAI_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 ${RUNNER_TEMP}/gh-aw/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
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 30
- GH_AW_VERSION: v0.86.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- 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
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl
- if: always()
- name: Validate gVisor bounded-agent invocation
- run: "node - \"$AUDIT_LOG\" \"$TELEMETRY_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);\nconst read = (file) => fs.readFileSync(file, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = read(auditPath).filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1) {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst successes = read(telemetryPath).filter((record) =>\n record.primaryBackend === \"docker\" &&\n record.boundedAgentBackend === \"gvisor\" &&\n record.lifecycleClass === \"invocation\" &&\n record.category === \"success\");\nif (successes.length !== 1) {\n throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`);\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE"
-
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
- needs.activation.outputs.daily_ai_credits_exceeded == 'true')
- runs-on: ubuntu-slim
- permissions:
- actions: read
- issues: write
- concurrency:
- group: "gh-aw-conclusion-smoke-bounded-agents-gvisor"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download Safe Outputs Items Manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh"
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache-conclusion
- if: always()
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Write daily AIC usage cache entry
- id: write-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
- await main();
- - name: Save daily AIC usage cache
- id: save-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }}
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Upload daily AIC usage cache artifact
- id: upload-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: aic-usage-cache
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- if-no-files-found: ignore
- retention-days: 7
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "false"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
- GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
- GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "30"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Report failed jobs
- id: report_failed_jobs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_REPORT_FAILED_JOBS: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_failed_jobs.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- if: (!cancelled()) && needs.agent.result != 'skipped'
- runs-on: ubuntu-slim
- permissions:
- issues: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents-gvisor"
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }}
- process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents-gvisor\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents-gvisor]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- if-no-files-found: ignore
diff --git a/.github/workflows/smoke-bounded-agents-gvisor.md b/.github/workflows/smoke-bounded-agents-gvisor.md
deleted file mode 100644
index 0ae26d8c8..000000000
--- a/.github/workflows/smoke-bounded-agents-gvisor.md
+++ /dev/null
@@ -1,143 +0,0 @@
----
-name: Smoke Bounded Agents gVisor
-description: End-to-end smoke test for finite-schema gVisor bounded-agent enclaves
-on:
- schedule: every 12h
- workflow_dispatch:
-permissions:
- contents: read
- copilot-requests: write
-env:
- GH_TOKEN: ${{ github.token }}
-engine:
- id: copilot
- version: 1.0.34
- env:
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
-network:
- allowed:
- - defaults
- - github
-tools:
- github:
- toolsets: [context]
- allowed: []
-sandbox:
- agent:
- id: awf
- version: v0.28.0
- args:
- - --build-local
-steps:
- - name: Build unreleased AWF
- run: |
- npm ci
- npm run build
-pre-agent-steps:
- - name: Install gVisor
- run: |
- set -euo pipefail
- arch="$(uname -m)"
- url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}"
- curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc"
- curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512"
- (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512)
- sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc
- sudo runsc install
- sudo systemctl restart docker
- docker info --format '{{json .Runtimes}}' | grep -F '"runsc"'
- - name: Replace release bootstrap with current AWF build
- run: |
- mkdir -p "$HOME/.local/bin"
- cat > "$HOME/.local/bin/configure-bounded-agent.cjs" <<'NODE'
- const fs = require("fs");
- const [file, runtime] = process.argv.slice(2);
- if (!file || !runtime) {
- throw new Error("usage: configure-bounded-agent.cjs ");
- }
- const config = JSON.parse(fs.readFileSync(file, "utf8"));
- config.apiProxy = { ...(config.apiProxy || {}), targets: { copilot: {} } };
- config.boundedAgents = {
- enabled: true,
- privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }],
- runtime,
- engine: "copilot",
- profile: "openai",
- model: "gpt-4o-mini",
- timeout: 540,
- memoryLimit: "512m"
- };
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
- NODE
- cat > "$HOME/.local/bin/awf" <<'SH'
- #!/bin/bash
- set -euo pipefail
- config_path=""
- args=("$@")
- for ((index = 0; index < ${#args[@]}; index++)); do
- if [[ "${args[$index]}" == "--config" && $((index + 1)) -lt ${#args[@]} ]]; then
- config_path="${args[$((index + 1))]}"
- break
- fi
- done
- if [[ -z "$config_path" ]]; then
- echo "bounded-agent smoke wrapper requires --config" >&2
- exit 2
- fi
- node "$HOME/.local/bin/configure-bounded-agent.cjs" "$config_path" gvisor
- exec node "$GITHUB_WORKSPACE/dist/cli.js" "$@"
- SH
- chmod +x "$HOME/.local/bin/awf"
-safe-outputs:
- threat-detection:
- enabled: false
-timeout-minutes: 30
-strict: false
-concurrency:
- group: smoke-bounded-agents-gvisor
- cancel-in-progress: false
-post-steps:
- - name: Validate gVisor bounded-agent invocation
- if: always()
- env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl
- TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- node - "$AUDIT_LOG" "$TELEMETRY_LOG" "$OUTPUTS_FILE" <<'NODE'
- const fs = require("fs");
- const [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);
- const read = (file) => fs.readFileSync(file, "utf8").trim().split("\n")
- .filter(Boolean).map((line) => JSON.parse(line));
- const invocations = read(auditPath).filter((record) =>
- record.kind === "invocation" && record.sensitivity === "internal");
- if (invocations.length !== 1) {
- throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);
- }
- const successes = read(telemetryPath).filter((record) =>
- record.primaryBackend === "docker" &&
- record.boundedAgentBackend === "gvisor" &&
- record.lifecycleClass === "invocation" &&
- record.category === "success");
- if (successes.length !== 1) {
- throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`);
- }
- const outputs = fs.readFileSync(outputsPath, "utf8");
- if (!outputs.includes('"noop"') || !outputs.includes("PASS")) {
- throw new Error("agent did not report PASS through noop");
- }
- NODE
----
-
-# Smoke Test: gVisor Bounded Agent
-
-Use the generated `bounded-agent` skill exactly once to answer this boolean
-question about `github/gh-aw`: does the repository root contain a `go.mod`
-file?
-
-Use a boolean schema. Do not use GitHub tools, network requests, shell commands,
-or the current checkout to answer. The test passes only when a fresh gVisor
-enclave returns `true`.
-
-Call `noop` with `PASS true` only when the result is true. Otherwise call
-`safeoutputs-missing_data`. Never report failure through `noop`.
diff --git a/.github/workflows/smoke-bounded-agents.lock.yml b/.github/workflows/smoke-bounded-agents.lock.yml
deleted file mode 100644
index a02ded535..000000000
--- a/.github/workflows/smoke-bounded-agents.lock.yml
+++ /dev/null
@@ -1,1380 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"5ade76cd5e383049863ab714f459e326dcb1ca2fef512ed66df6222c8fdadc45","body_hash":"e86fc590352c926acbc317b5f565ea542ef92789a891987365678bf5365c6308","compiler_version":"v0.86.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}}
-# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"19356acbcf6b0677aa06bacc1b9894fe883ae751","version":"v0.86.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"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.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]}
-# This file was automatically generated by gh-aw (v0.86.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# End-to-end smoke test for finite-schema Docker bounded-agent enclaves
-#
-# Frontmatter env variables:
-# - GH_TOKEN: (main workflow)
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
-#
-# Container images used:
-# -
-# -
-# -
-# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8
-# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b
-# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
-
-name: "Smoke Bounded Agents"
-on:
- schedule:
- - cron: "12 */12 * * *" # Friendly format: every 12h (scattered)
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- cancel-in-progress: false
- group: smoke-bounded-agents
-
-run-name: "Smoke Bounded Agents"
-
-env:
- GH_TOKEN: ${{ github.token }}
-
-jobs:
- activation:
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
- daily_ai_credits_guardrail_status: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }}
- daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
- daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AGENT_VERSION: "1.0.34"
- GH_AW_INFO_CLI_VERSION: "v0.86.0"
- GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Enforce strict mode policy
- if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }}
- run: |
- echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true."
- exit 1
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedagents-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Restore daily AIC usage cache (artifact fallback)
- id: restore-daily-aic-cache-fallback
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
- GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
- await main();
- - name: Check daily workflow token guardrail
- id: daily-effective-workflow-guardrail
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
- GH_AW_HAS_SLASH_COMMAND: "false"
- GH_AW_HAS_LABEL_COMMAND: "false"
- GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
- await main();
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "smoke-bounded-agents.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.86.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF'
-
- GH_AW_PROMPT_1498a4d66b22842d_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_1498a4d66b22842d_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md"
- cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF'
-
- GH_AW_PROMPT_1498a4d66b22842d_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_1498a4d66b22842d_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF'
-
- {{#runtime-import .github/workflows/smoke-bounded-agents.md}}
- GH_AW_PROMPT_1498a4d66b22842d_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs: activation
- if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagents
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Build unreleased AWF
- run: |-
- npm ci
- npm run build
-
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34
- env:
- GH_HOST: github.com
- - 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 < \"$HOME/.local/bin/configure-bounded-agent.cjs\" <<'NODE'\nconst fs = require(\"fs\");\nconst [file, runtime] = process.argv.slice(2);\nif (!file || !runtime) {\n throw new Error(\"usage: configure-bounded-agent.cjs \");\n}\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { copilot: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime,\n engine: \"copilot\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n timeout: 540,\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE\ncat > \"$HOME/.local/bin/awf\" <<'SH'\n#!/bin/bash\nset -euo pipefail\nconfig_path=\"\"\nargs=(\"$@\")\nfor ((index = 0; index < ${#args[@]}; index++)); do\n if [[ \"${args[$index]}\" == \"--config\" && $((index + 1)) -lt ${#args[@]} ]]; then\n config_path=\"${args[$((index + 1))]}\"\n break\n fi\ndone\nif [[ -z \"$config_path\" ]]; then\n echo \"bounded-agent smoke wrapper requires --config\" >&2\n exit 2\nfi\nnode \"$HOME/.local/bin/configure-bounded-agent.cjs\" \"$config_path\" docker\nexec node \"$GITHUB_WORKSPACE/dist/cli.js\" \"$@\"\nSH\nchmod +x \"$HOME/.local/bin/awf\""
-
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_522efcaae2b767b7_EOF'
- {"create_issue":{"labels":["smoke-bounded-agents"],"max":1,"title_prefix":"[smoke-bounded-agents]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_522efcaae2b767b7_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents]\". Labels [\"smoke-bounded-agents\"] will be automatically added."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20
- },
- "fields": {
- "type": "array"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # 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"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- 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 GH_AW_SINK_VISIBILITY -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.4.8'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.8.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- 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_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 30
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)"
- if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then
- echo "GitHub Copilot CLI executable not found on PATH after installation" >&2
- exit 127
- fi
- GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot"
- mkdir -p "${RUNNER_TEMP}/gh-aw/bin"
- if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then
- cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN"
- fi
- chmod 755 "$GH_AW_COPILOT_BIN"
-
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- 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 ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --exclude-env OPENAI_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 ${RUNNER_TEMP}/gh-aw/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
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 30
- GH_AW_VERSION: v0.86.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- 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
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl
- if: always()
- name: Validate bounded-agent invocation
- run: "node - \"$AUDIT_LOG\" \"$TELEMETRY_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);\nconst read = (file) => fs.readFileSync(file, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst records = read(auditPath);\nconst invocations = records.filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1) {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst successes = read(telemetryPath).filter((record) =>\n record.primaryBackend === \"docker\" &&\n record.boundedAgentBackend === \"docker\" &&\n record.lifecycleClass === \"invocation\" &&\n record.category === \"success\");\nif (successes.length !== 1) {\n throw new Error(`expected one successful Docker telemetry record, found ${successes.length}`);\n}\nconst serialized = JSON.stringify(records);\nif (serialized.includes(\"github/gh-aw\") || serialized.includes(\"SECURITY.md\")) {\n throw new Error(\"protected audit disclosed repository-derived content\");\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE"
-
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
- needs.activation.outputs.daily_ai_credits_exceeded == 'true')
- runs-on: ubuntu-slim
- permissions:
- actions: read
- issues: write
- concurrency:
- group: "gh-aw-conclusion-smoke-bounded-agents"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download Safe Outputs Items Manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh"
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache-conclusion
- if: always()
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedagents-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Write daily AIC usage cache entry
- id: write-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
- await main();
- - name: Save daily AIC usage cache
- id: save-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }}
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Upload daily AIC usage cache artifact
- id: upload-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: aic-usage-cache
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- if-no-files-found: ignore
- retention-days: 7
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "false"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
- GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
- GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "30"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Report failed jobs
- id: report_failed_jobs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_REPORT_FAILED_JOBS: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_failed_jobs.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- if: (!cancelled()) && needs.agent.result != 'skipped'
- runs-on: ubuntu-slim
- permissions:
- issues: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents"
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-agents"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }}
- process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- if-no-files-found: ignore
diff --git a/.github/workflows/smoke-bounded-agents.md b/.github/workflows/smoke-bounded-agents.md
deleted file mode 100644
index 8e665ae40..000000000
--- a/.github/workflows/smoke-bounded-agents.md
+++ /dev/null
@@ -1,136 +0,0 @@
----
-name: Smoke Bounded Agents
-description: End-to-end smoke test for finite-schema Docker bounded-agent enclaves
-on:
- schedule: every 12h
- workflow_dispatch:
-permissions:
- contents: read
- copilot-requests: write
-env:
- GH_TOKEN: ${{ github.token }}
-engine:
- id: copilot
- version: 1.0.34
- env:
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
-network:
- allowed:
- - defaults
- - github
-tools:
- github:
- toolsets: [context]
- allowed: []
-sandbox:
- agent:
- id: awf
- version: v0.28.0
- args:
- - --build-local
-steps:
- - name: Build unreleased AWF
- run: |
- npm ci
- npm run build
-pre-agent-steps:
- - name: Replace release bootstrap with current AWF build
- run: |
- mkdir -p "$HOME/.local/bin"
- cat > "$HOME/.local/bin/configure-bounded-agent.cjs" <<'NODE'
- const fs = require("fs");
- const [file, runtime] = process.argv.slice(2);
- if (!file || !runtime) {
- throw new Error("usage: configure-bounded-agent.cjs ");
- }
- const config = JSON.parse(fs.readFileSync(file, "utf8"));
- config.apiProxy = { ...(config.apiProxy || {}), targets: { copilot: {} } };
- config.boundedAgents = {
- enabled: true,
- privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }],
- runtime,
- engine: "copilot",
- profile: "openai",
- model: "gpt-4o-mini",
- timeout: 540,
- memoryLimit: "512m"
- };
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
- NODE
- cat > "$HOME/.local/bin/awf" <<'SH'
- #!/bin/bash
- set -euo pipefail
- config_path=""
- args=("$@")
- for ((index = 0; index < ${#args[@]}; index++)); do
- if [[ "${args[$index]}" == "--config" && $((index + 1)) -lt ${#args[@]} ]]; then
- config_path="${args[$((index + 1))]}"
- break
- fi
- done
- if [[ -z "$config_path" ]]; then
- echo "bounded-agent smoke wrapper requires --config" >&2
- exit 2
- fi
- node "$HOME/.local/bin/configure-bounded-agent.cjs" "$config_path" docker
- exec node "$GITHUB_WORKSPACE/dist/cli.js" "$@"
- SH
- chmod +x "$HOME/.local/bin/awf"
-safe-outputs:
- threat-detection:
- enabled: false
-timeout-minutes: 30
-strict: false
-concurrency:
- group: smoke-bounded-agents
- cancel-in-progress: false
-post-steps:
- - name: Validate bounded-agent invocation
- if: always()
- env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl
- TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- node - "$AUDIT_LOG" "$TELEMETRY_LOG" "$OUTPUTS_FILE" <<'NODE'
- const fs = require("fs");
- const [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);
- const read = (file) => fs.readFileSync(file, "utf8").trim().split("\n")
- .filter(Boolean).map((line) => JSON.parse(line));
- const records = read(auditPath);
- const invocations = records.filter((record) =>
- record.kind === "invocation" && record.sensitivity === "internal");
- if (invocations.length !== 1) {
- throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);
- }
- const successes = read(telemetryPath).filter((record) =>
- record.primaryBackend === "docker" &&
- record.boundedAgentBackend === "docker" &&
- record.lifecycleClass === "invocation" &&
- record.category === "success");
- if (successes.length !== 1) {
- throw new Error(`expected one successful Docker telemetry record, found ${successes.length}`);
- }
- const serialized = JSON.stringify(records);
- if (serialized.includes("github/gh-aw") || serialized.includes("SECURITY.md")) {
- throw new Error("protected audit disclosed repository-derived content");
- }
- const outputs = fs.readFileSync(outputsPath, "utf8");
- if (!outputs.includes('"noop"') || !outputs.includes("PASS")) {
- throw new Error("agent did not report PASS through noop");
- }
- NODE
----
-
-# Smoke Test: Docker Bounded Agent
-
-Use the generated `bounded-agent` skill exactly once to answer this boolean
-question about `github/gh-aw`: does the repository root contain a `go.mod`
-file?
-
-Use a boolean schema. Do not use GitHub tools, network requests, shell commands,
-or the current checkout to answer. The test passes only when the bounded agent
-returns `true`.
-
-Call `noop` with `PASS true` only when the result is true. Otherwise call
-`safeoutputs-missing_data`. Never report failure through `noop`.
diff --git a/.github/workflows/smoke-bounded-queries-gvisor.lock.yml b/.github/workflows/smoke-bounded-queries-gvisor.lock.yml
deleted file mode 100644
index a4cc8f105..000000000
--- a/.github/workflows/smoke-bounded-queries-gvisor.lock.yml
+++ /dev/null
@@ -1,1457 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1b8e71ee4ac9fdff7f975aaab0bccf00cf864b568022468cd4baa339c4821d45","body_hash":"130eec124f5cbc455d037aa81ec6c338a03c1dbd2b5e309bf5a9920b9aa04219","compiler_version":"v0.86.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}}
-# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"19356acbcf6b0677aa06bacc1b9894fe883ae751","version":"v0.86.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"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.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]}
-# This file was automatically generated by gh-aw (v0.86.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# End-to-end smoke test for bounded queries in fresh gVisor sandboxes
-#
-# Frontmatter env variables:
-# - GH_TOKEN: (main workflow)
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
-#
-# Container images used:
-# -
-# -
-# -
-# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8
-# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b
-# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
-
-name: "Smoke Bounded Queries gVisor"
-on:
- schedule:
- - cron: "19 */12 * * *" # Friendly format: every 12h (scattered)
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- cancel-in-progress: false
- group: smoke-bounded-queries-gvisor
-
-run-name: "Smoke Bounded Queries gVisor"
-
-env:
- GH_TOKEN: ${{ github.token }}
-
-jobs:
- activation:
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
- daily_ai_credits_guardrail_status: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }}
- daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
- daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AGENT_VERSION: "1.0.34"
- GH_AW_INFO_CLI_VERSION: "v0.86.0"
- GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Enforce strict mode policy
- if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }}
- run: |
- echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true."
- exit 1
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriesgvisor-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueriesgvisor-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Restore daily AIC usage cache (artifact fallback)
- id: restore-daily-aic-cache-fallback
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
- GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
- await main();
- - name: Check daily workflow token guardrail
- id: daily-effective-workflow-guardrail
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-gvisor"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
- GH_AW_HAS_SLASH_COMMAND: "false"
- GH_AW_HAS_LABEL_COMMAND: "false"
- GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
- await main();
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "smoke-bounded-queries-gvisor.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.86.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_976402bdccc16736_EOF'
-
- GH_AW_PROMPT_976402bdccc16736_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_976402bdccc16736_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_976402bdccc16736_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md"
- cat << 'GH_AW_PROMPT_976402bdccc16736_EOF'
-
- GH_AW_PROMPT_976402bdccc16736_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_976402bdccc16736_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_976402bdccc16736_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_976402bdccc16736_EOF'
-
- {{#runtime-import .github/workflows/smoke-bounded-queries-gvisor.md}}
- GH_AW_PROMPT_976402bdccc16736_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs:
- - activation
- - verify_budget_matrix
- if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedqueriesgvisor
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Build unreleased AWF
- run: |-
- npm ci
- npm run build
-
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34
- env:
- GH_HOST: github.com
- - 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 < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\""
-
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_0bde5d31ad0f3463_EOF'
- {"create_issue":{"labels":["smoke-bounded-queries-gvisor"],"max":1,"title_prefix":"[smoke-bounded-queries-gvisor]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_0bde5d31ad0f3463_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-queries-gvisor]\". Labels [\"smoke-bounded-queries-gvisor\"] will be automatically added."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20
- },
- "fields": {
- "type": "array"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # 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"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- 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 GH_AW_SINK_VISIBILITY -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.4.8'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.8.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- 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_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 30
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)"
- if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then
- echo "GitHub Copilot CLI executable not found on PATH after installation" >&2
- exit 127
- fi
- GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot"
- mkdir -p "${RUNNER_TEMP}/gh-aw/bin"
- if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then
- cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN"
- fi
- chmod 755 "$GH_AW_COPILOT_BIN"
-
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"boundedQueries\":{\"enabled\":true,\"privateRepos\":[{\"repo\":\"github/gh-aw\",\"sensitivity\":\"internal\"}],\"runtime\":\"gvisor\",\"memoryLimit\":\"2g\",\"interpreter\":\"python3\"},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- 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 ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --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 ${RUNNER_TEMP}/gh-aw/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
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 30
- GH_AW_VERSION: v0.86.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- 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
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-query.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/runtime-telemetry.jsonl
- if: always()
- name: Validate gVisor bounded-query invocation
- run: "node - \"$AUDIT_LOG\" \"$TELEMETRY_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);\nconst readJsonLines = (path) => fs.readFileSync(path, \"utf8\")\n .trim()\n .split(\"\\n\")\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n\nconst invocations = readJsonLines(auditPath).filter(\n (record) => record.kind === \"invocation\" &&\n record.repo === \"github/gh-aw\" &&\n record.sensitivity === \"internal\"\n);\nif (invocations.length !== 1) {\n throw new Error(`expected one successful bounded query, found ${invocations.length}`);\n}\n\nconst successfulQueries = readJsonLines(telemetryPath).filter(\n (record) => record.primaryBackend === \"docker\" &&\n record.queryBackend === \"gvisor\" &&\n record.lifecycleClass === \"query\" &&\n record.capabilityState === \"supported\" &&\n record.category === \"success\"\n);\nif (successfulQueries.length !== 1) {\n throw new Error(`expected one successful gVisor query telemetry record, found ${successfulQueries.length}`);\n}\n\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report a bounded-query PASS through noop\");\n}\nNODE"
-
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- - verify_budget_matrix
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
- needs.activation.outputs.daily_ai_credits_exceeded == 'true')
- runs-on: ubuntu-slim
- permissions:
- actions: read
- issues: write
- concurrency:
- group: "gh-aw-conclusion-smoke-bounded-queries-gvisor"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download Safe Outputs Items Manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh"
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache-conclusion
- if: always()
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriesgvisor-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueriesgvisor-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Write daily AIC usage cache entry
- id: write-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
- await main();
- - name: Save daily AIC usage cache
- id: save-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriesgvisor-${{ github.run_id }}
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Upload daily AIC usage cache artifact
- id: upload-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: aic-usage-cache
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- if-no-files-found: ignore
- retention-days: 7
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "false"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-gvisor"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-gvisor"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
- GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
- GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "30"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Report failed jobs
- id: report_failed_jobs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_REPORT_FAILED_JOBS: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_failed_jobs.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- if: (!cancelled()) && needs.agent.result != 'skipped'
- runs-on: ubuntu-slim
- permissions:
- issues: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-queries-gvisor"
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-gvisor"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-gvisor.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }}
- process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries gVisor"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-gvisor.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-queries-gvisor\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-queries-gvisor]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- if-no-files-found: ignore
-
- verify_budget_matrix:
- name: Verify gVisor confidentiality budgets
- needs: activation
- runs-on: ubuntu-latest
- permissions:
- contents: read
- timeout-minutes: 30
- steps:
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Install gVisor
- run: |
- set -euo pipefail
- arch="$(uname -m)"
- url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}"
- curl -fsSL "${url}/runsc" -o /tmp/runsc
- curl -fsSL "${url}/runsc.sha512" -o /tmp/runsc.sha512
- (cd /tmp && sha512sum -c runsc.sha512)
- curl -fsSL "${url}/containerd-shim-runsc-v1" -o /tmp/containerd-shim-runsc-v1
- curl -fsSL "${url}/containerd-shim-runsc-v1.sha512" -o /tmp/containerd-shim-runsc-v1.sha512
- (cd /tmp && sha512sum -c containerd-shim-runsc-v1.sha512)
- 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
- sudo runsc install
- sudo systemctl restart docker
- docker info --format '{{json .Runtimes}}' | grep -F '"runsc"'
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < "$HOME/.local/bin/awf"
- chmod +x "$HOME/.local/bin/awf"
-safe-outputs:
- threat-detection:
- enabled: false
-timeout-minutes: 30
-strict: false
-concurrency:
- group: smoke-bounded-queries-gvisor
- cancel-in-progress: false
-jobs:
- verify_budget_matrix:
- name: Verify gVisor confidentiality budgets
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Install gVisor
- run: |
- set -euo pipefail
- arch="$(uname -m)"
- url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}"
- curl -fsSL "${url}/runsc" -o /tmp/runsc
- curl -fsSL "${url}/runsc.sha512" -o /tmp/runsc.sha512
- (cd /tmp && sha512sum -c runsc.sha512)
- curl -fsSL "${url}/containerd-shim-runsc-v1" -o /tmp/containerd-shim-runsc-v1
- curl -fsSL "${url}/containerd-shim-runsc-v1.sha512" -o /tmp/containerd-shim-runsc-v1.sha512
- (cd /tmp && sha512sum -c containerd-shim-runsc-v1.sha512)
- 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
- sudo runsc install
- sudo systemctl restart docker
- docker info --format '{{json .Runtimes}}' | grep -F '"runsc"'
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < fs.readFileSync(path, "utf8")
- .trim()
- .split("\n")
- .filter(Boolean)
- .map((line) => JSON.parse(line));
-
- const invocations = readJsonLines(auditPath).filter(
- (record) => record.kind === "invocation" &&
- record.repo === "github/gh-aw" &&
- record.sensitivity === "internal"
- );
- if (invocations.length !== 1) {
- throw new Error(`expected one successful bounded query, found ${invocations.length}`);
- }
-
- const successfulQueries = readJsonLines(telemetryPath).filter(
- (record) => record.primaryBackend === "docker" &&
- record.queryBackend === "gvisor" &&
- record.lifecycleClass === "query" &&
- record.capabilityState === "supported" &&
- record.category === "success"
- );
- if (successfulQueries.length !== 1) {
- throw new Error(`expected one successful gVisor query telemetry record, found ${successfulQueries.length}`);
- }
-
- const outputs = fs.readFileSync(outputsPath, "utf8");
- if (!outputs.includes('"noop"') || !outputs.includes("PASS")) {
- throw new Error("agent did not report a bounded-query PASS through noop");
- }
- NODE
----
-
-# Smoke Test: gVisor Bounded Queries
-
-Use the generated `bounded-query` skill to answer exactly one finite question about
-`github/gh-aw`: does the repository root contain a `go.mod` file?
-
-The query must:
-
-1. Use a boolean JSON schema.
-2. Run a Python script inside the bounded-query environment that checks
- `/query/repo/go.mod`.
-3. Return `true`.
-4. Wait for the command to finish without interrupting it. Internal queries
- intentionally return on a 10-minute confidentiality timing bucket, so this
- latency is expected and is not a hang.
-
-No GitHub API tools are available to the agent. Do not use network requests or
-the current checkout to answer the question. The test passes only when the
-query runs in a fresh gVisor sandbox, succeeds, and returns `true`.
-
-Call `noop` with a concise PASS result that includes the returned boolean only
-when the query returns `true`. If the skill is unavailable, call
-`safeoutputs-missing_tool`. If the query fails or returns anything other than
-`true`, call `safeoutputs-missing_data`. Never report FAIL through `noop`.
diff --git a/.github/workflows/smoke-bounded-queries-sbx.lock.yml b/.github/workflows/smoke-bounded-queries-sbx.lock.yml
deleted file mode 100644
index c45694c6a..000000000
--- a/.github/workflows/smoke-bounded-queries-sbx.lock.yml
+++ /dev/null
@@ -1,1425 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2c3a1bb799b00765c02b04fbc6d15e5b93ed776eeade6c1b3fc7b47262d8c25e","body_hash":"c2c25984a72a9ad5098d7b56498e8abb141d2301cfc46b8d8482c5d698746cfe","compiler_version":"v0.86.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}}
-# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"19356acbcf6b0677aa06bacc1b9894fe883ae751","version":"v0.86.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"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.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]}
-# This file was automatically generated by gh-aw (v0.86.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# End-to-end smoke test for the fail-closed sbx bounded-query capability gate
-#
-# Frontmatter env variables:
-# - GH_TOKEN: (main workflow)
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
-#
-# Container images used:
-# -
-# -
-# -
-# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8
-# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b
-# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
-
-name: "Smoke Bounded Queries sbx"
-on:
- schedule:
- - cron: "50 */12 * * *" # Friendly format: every 12h (scattered)
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- cancel-in-progress: false
- group: smoke-bounded-queries-sbx
-
-run-name: "Smoke Bounded Queries sbx"
-
-env:
- GH_TOKEN: ${{ github.token }}
-
-jobs:
- activation:
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
- daily_ai_credits_guardrail_status: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }}
- daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
- daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-sbx.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AGENT_VERSION: "1.0.34"
- GH_AW_INFO_CLI_VERSION: "v0.86.0"
- GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Enforce strict mode policy
- if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }}
- run: |
- echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true."
- exit 1
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriessbx-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueriessbx-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Restore daily AIC usage cache (artifact fallback)
- id: restore-daily-aic-cache-fallback
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
- GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
- await main();
- - name: Check daily workflow token guardrail
- id: daily-effective-workflow-guardrail
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-sbx"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
- GH_AW_HAS_SLASH_COMMAND: "false"
- GH_AW_HAS_LABEL_COMMAND: "false"
- GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
- await main();
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "smoke-bounded-queries-sbx.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.86.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_570be49333954ea5_EOF'
-
- GH_AW_PROMPT_570be49333954ea5_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_570be49333954ea5_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_570be49333954ea5_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md"
- cat << 'GH_AW_PROMPT_570be49333954ea5_EOF'
-
- GH_AW_PROMPT_570be49333954ea5_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_570be49333954ea5_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_570be49333954ea5_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_570be49333954ea5_EOF'
-
- {{#runtime-import .github/workflows/smoke-bounded-queries-sbx.md}}
- GH_AW_PROMPT_570be49333954ea5_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs:
- - activation
- - verify_sbx_gate
- if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedqueriessbx
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-sbx.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Build unreleased AWF
- run: |-
- npm ci
- npm run build
-
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34
- env:
- GH_HOST: github.com
- - 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 < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\""
-
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_43fceb264367e827_EOF'
- {"create_issue":{"labels":["smoke-bounded-queries-sbx"],"max":1,"title_prefix":"[smoke-bounded-queries-sbx]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_43fceb264367e827_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-queries-sbx]\". Labels [\"smoke-bounded-queries-sbx\"] will be automatically added."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20
- },
- "fields": {
- "type": "array"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # 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"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- 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 GH_AW_SINK_VISIBILITY -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.4.8'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.8.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- 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_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 15
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)"
- if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then
- echo "GitHub Copilot CLI executable not found on PATH after installation" >&2
- exit 127
- fi
- GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot"
- mkdir -p "${RUNNER_TEMP}/gh-aw/bin"
- if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then
- cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN"
- fi
- chmod 755 "$GH_AW_COPILOT_BIN"
-
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"boundedQueries\":{\"enabled\":true,\"privateRepos\":[{\"repo\":\"github/gh-aw\",\"sensitivity\":\"internal\"}],\"runtime\":\"docker\",\"memoryLimit\":\"2g\",\"interpreter\":\"python3\"},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- 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 ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --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 ${RUNNER_TEMP}/gh-aw/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
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 15
- GH_AW_VERSION: v0.86.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- 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
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-query.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- if: always()
- name: Validate Docker control invocation
- run: "node - \"$AUDIT_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, outputsPath] = process.argv.slice(2);\nconst invocations = fs.readFileSync(auditPath, \"utf8\")\n .trim()\n .split(\"\\n\")\n .filter(Boolean)\n .map((line) => JSON.parse(line))\n .filter((record) => record.kind === \"invocation\" &&\n record.repo === \"github/gh-aw\" &&\n record.sensitivity === \"internal\");\nif (invocations.length !== 1) {\n throw new Error(`expected one successful Docker control query, found ${invocations.length}`);\n}\n\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report a bounded-query PASS through noop\");\n}\nNODE"
-
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- - verify_sbx_gate
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
- needs.activation.outputs.daily_ai_credits_exceeded == 'true')
- runs-on: ubuntu-slim
- permissions:
- actions: read
- issues: write
- concurrency:
- group: "gh-aw-conclusion-smoke-bounded-queries-sbx"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-sbx.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download Safe Outputs Items Manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh"
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache-conclusion
- if: always()
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriessbx-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueriessbx-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Write daily AIC usage cache entry
- id: write-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
- await main();
- - name: Save daily AIC usage cache
- id: save-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueriessbx-${{ github.run_id }}
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Upload daily AIC usage cache artifact
- id: upload-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: aic-usage-cache
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- if-no-files-found: ignore
- retention-days: 7
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "false"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-sbx"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-sbx"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
- GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
- GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "15"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Report failed jobs
- id: report_failed_jobs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_REPORT_FAILED_JOBS: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_failed_jobs.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- if: (!cancelled()) && needs.agent.result != 'skipped'
- runs-on: ubuntu-slim
- permissions:
- issues: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-queries-sbx"
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries-sbx"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries-sbx.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }}
- process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries sbx"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries-sbx.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-queries-sbx\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-queries-sbx]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- if-no-files-found: ignore
-
- verify_sbx_gate:
- name: Verify sbx fails closed
- needs: activation
- runs-on: ubuntu-latest
- permissions:
- contents: read
- timeout-minutes: 30
- steps:
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < "$HOME/.local/bin/awf"
- chmod +x "$HOME/.local/bin/awf"
-safe-outputs:
- threat-detection:
- enabled: false
-timeout-minutes: 15
-strict: false
-concurrency:
- group: smoke-bounded-queries-sbx
- cancel-in-progress: false
-jobs:
- verify_sbx_gate:
- name: Verify sbx fails closed
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < JSON.parse(line))
- .filter((record) => record.kind === "invocation" &&
- record.repo === "github/gh-aw" &&
- record.sensitivity === "internal");
- if (invocations.length !== 1) {
- throw new Error(`expected one successful Docker control query, found ${invocations.length}`);
- }
-
- const outputs = fs.readFileSync(outputsPath, "utf8");
- if (!outputs.includes('"noop"') || !outputs.includes("PASS")) {
- throw new Error("agent did not report a bounded-query PASS through noop");
- }
- NODE
----
-
-# Smoke Test: sbx Bounded-Query Security Gate
-
-The deterministic `verify_sbx_gate` job verifies that AWF rejects sbx bounded
-queries before staging or agent startup while the audited sbx runtime lacks the
-mandatory pinned template, no-network, PID, disk, file-size, and guest
-mount-target controls. It also verifies that no Docker or gVisor fallback is
-attempted.
-
-For the agent path, use the generated `bounded-query` skill once as a Docker
-control. Ask whether `/query/repo/go.mod` exists in `github/gh-aw` with a
-boolean schema and return `true`.
-
-Call `noop` with a concise PASS result that includes the returned boolean only
-when the control query returns `true`. If the skill is unavailable, call
-`safeoutputs-missing_tool`. If the query fails or returns anything other than
-`true`, call `safeoutputs-missing_data`. Never report FAIL through `noop`.
diff --git a/.github/workflows/smoke-bounded-queries.lock.yml b/.github/workflows/smoke-bounded-queries.lock.yml
deleted file mode 100644
index 22e124da0..000000000
--- a/.github/workflows/smoke-bounded-queries.lock.yml
+++ /dev/null
@@ -1,1393 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a0c71c7c860b14e1fe61250c85c95d32df619362fecbce9461121ec0d305b551","body_hash":"8fdc08837281ad8fcc2dd63e68127d033d6c1d7f987996aba9fa1326523d7231","compiler_version":"v0.86.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}}
-# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"19356acbcf6b0677aa06bacc1b9894fe883ae751","version":"v0.86.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"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.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]}
-# This file was automatically generated by gh-aw (v0.86.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# Smoke test for declarative bounded queries in agentic workflow frontmatter
-#
-# Frontmatter env variables:
-# - GH_TOKEN: (main workflow)
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
-#
-# Container images used:
-# - ghcr.io/github/gh-aw-firewall/agent:0.28.0
-# - ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0
-# - ghcr.io/github/gh-aw-firewall/squid:0.28.0
-# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8
-# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b
-# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
-
-name: "Smoke Bounded Queries"
-on:
- schedule:
- - cron: "36 */12 * * *" # Friendly format: every 12h (scattered)
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- cancel-in-progress: false
- group: smoke-bounded-queries
-
-run-name: "Smoke Bounded Queries"
-
-env:
- GH_TOKEN: ${{ github.token }}
-
-jobs:
- activation:
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
- daily_ai_credits_guardrail_status: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }}
- daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
- daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AGENT_VERSION: "1.0.34"
- GH_AW_INFO_CLI_VERSION: "v0.86.0"
- GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Enforce strict mode policy
- if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }}
- run: |
- echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true."
- exit 1
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueries-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueries-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Restore daily AIC usage cache (artifact fallback)
- id: restore-daily-aic-cache-fallback
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
- GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
- await main();
- - name: Check daily workflow token guardrail
- id: daily-effective-workflow-guardrail
- if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
- GH_AW_HAS_SLASH_COMMAND: "false"
- GH_AW_HAS_LABEL_COMMAND: "false"
- GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
- await main();
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout: |
- .github
- .agents
- .claude
- .codex
- .gemini
- .pi
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "smoke-bounded-queries.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.86.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_54d2ece6321498b7_EOF'
-
- GH_AW_PROMPT_54d2ece6321498b7_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_54d2ece6321498b7_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_54d2ece6321498b7_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md"
- cat << 'GH_AW_PROMPT_54d2ece6321498b7_EOF'
-
- GH_AW_PROMPT_54d2ece6321498b7_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_54d2ece6321498b7_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_54d2ece6321498b7_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_54d2ece6321498b7_EOF'
-
- {{#runtime-import .github/workflows/smoke-bounded-queries.md}}
- GH_AW_PROMPT_54d2ece6321498b7_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: |
- # poutine:ignore untrusted_checkout_exec
- bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs:
- - activation
- - verify_budget_matrix
- if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedqueries
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Build unreleased AWF
- run: |-
- npm ci
- npm run build
-
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34
- env:
- GH_HOST: github.com
- - name: Install AWF binary
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.28.0 --rootless
- - name: Determine automatic lockdown mode for GitHub MCP Server
- id: determine-automatic-lockdown
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
- env:
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- with:
- script: |
- const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
- await determineAutomaticLockdown(github, context, core);
- - name: Restore agent config folders from base branch
- if: steps.checkout-pr.outcome == 'success'
- env:
- GH_AW_AGENT_FOLDERS: ".agents .github"
- GH_AW_AGENT_FILES: "AGENTS.md"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
- - name: Restore inline sub-agents from activation artifact
- env:
- GH_AW_SUB_AGENT_DIR: ".github/agents"
- GH_AW_SUB_AGENT_EXT: ".agent.md"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh"
- - name: Restore inline skills from activation artifact
- env:
- GH_AW_SKILL_DIR: ".github/skills"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- - name: Replace release bootstrap with current AWF build
- run: "mkdir -p \"$HOME/.local/bin\"\nprintf '#!/bin/bash\\nexec \"%s\" \"%s/dist/cli.js\" \"$@\"\\n' \\\n \"$(command -v node)\" \"$GITHUB_WORKSPACE\" > \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\""
-
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0 ghcr.io/github/gh-aw-firewall/squid:0.28.0 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_de27f56e59402ee7_EOF'
- {"create_issue":{"labels":["smoke-bounded-queries"],"max":1,"title_prefix":"[smoke-bounded-queries]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_de27f56e59402ee7_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-queries]\". Labels [\"smoke-bounded-queries\"] will be automatically added."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20
- },
- "fields": {
- "type": "array"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # 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"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- 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 GH_AW_SINK_VISIBILITY -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.4.8'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.8.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- 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_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 15
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)"
- if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then
- echo "GitHub Copilot CLI executable not found on PATH after installation" >&2
- exit 127
- fi
- GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot"
- mkdir -p "${RUNNER_TEMP}/gh-aw/bin"
- if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then
- cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN"
- fi
- chmod 755 "$GH_AW_COPILOT_BIN"
-
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"boundedQueries\":{\"enabled\":true,\"privateRepos\":[{\"repo\":\"github/gh-aw\",\"sensitivity\":\"internal\"}],\"runtime\":\"docker\",\"memoryLimit\":\"2g\",\"interpreter\":\"python3\"},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- 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 ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull --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 ${RUNNER_TEMP}/gh-aw/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
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 15
- GH_AW_VERSION: v0.86.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - 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"
- - name: Stop MCP Gateway
- if: always()
- continue-on-error: true
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - env:
- AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-query.jsonl
- OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- if: always()
- name: Validate bounded-query invocation
- run: "node - \"$AUDIT_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, outputsPath] = process.argv.slice(2);\nconst readJsonLines = (path) => fs.readFileSync(path, \"utf8\")\n .trim()\n .split(\"\\n\")\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n\nconst invocations = readJsonLines(auditPath).filter(\n (record) => record.kind === \"invocation\" &&\n record.repo === \"github/gh-aw\" &&\n record.sensitivity === \"internal\"\n);\nif (invocations.length !== 1) {\n throw new Error(`expected one successful bounded query, found ${invocations.length}`);\n}\n\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report a bounded-query PASS through noop\");\n}\nNODE"
-
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- - verify_budget_matrix
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
- needs.activation.outputs.daily_ai_credits_exceeded == 'true')
- runs-on: ubuntu-slim
- permissions:
- actions: read
- issues: write
- concurrency:
- group: "gh-aw-conclusion-smoke-bounded-queries"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download Safe Outputs Items Manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh"
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Restore daily AIC usage cache
- id: restore-daily-aic-cache-conclusion
- if: always()
- continue-on-error: true
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueries-${{ github.run_id }}
- restore-keys: agentic-workflow-usage-smokeboundedqueries-
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Write daily AIC usage cache entry
- id: write-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
- await main();
- - name: Save daily AIC usage cache
- id: save-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- key: agentic-workflow-usage-smokeboundedqueries-${{ github.run_id }}
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- - name: Upload daily AIC usage cache artifact
- id: upload-daily-aic-cache
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: aic-usage-cache
- path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
- if-no-files-found: ignore
- retention-days: 7
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "false"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
- GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
- GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "15"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Report failed jobs
- id: report_failed_jobs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_REPORT_FAILED_JOBS: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_failed_jobs.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- if: (!cancelled()) && needs.agent.result != 'skipped'
- runs-on: ubuntu-slim
- permissions:
- issues: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-queries"
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.34"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID: "smoke-bounded-queries"
- GH_AW_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-queries.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }}
- process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@19356acbcf6b0677aa06bacc1b9894fe883ae751 # v0.86.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Queries"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-queries.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.34"
- GH_AW_INFO_AWF_VERSION: "v0.28.0"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-queries\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-queries]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- if-no-files-found: ignore
-
- verify_budget_matrix:
- name: Verify confidentiality budgets
- needs: activation
- runs-on: ubuntu-latest
- permissions:
- contents: read
- timeout-minutes: 30
- steps:
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < "$HOME/.local/bin/awf"
- chmod +x "$HOME/.local/bin/awf"
-safe-outputs:
- threat-detection:
- enabled: false
-timeout-minutes: 15
-strict: false
-concurrency:
- group: smoke-bounded-queries
- cancel-in-progress: false
-jobs:
- verify_budget_matrix:
- name: Verify confidentiality budgets
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
- - name: Build AWF
- run: |
- npm ci
- npm run build
- sudo tee /usr/local/bin/awf > /dev/null < fs.readFileSync(path, "utf8")
- .trim()
- .split("\n")
- .filter(Boolean)
- .map((line) => JSON.parse(line));
-
- const invocations = readJsonLines(auditPath).filter(
- (record) => record.kind === "invocation" &&
- record.repo === "github/gh-aw" &&
- record.sensitivity === "internal"
- );
- if (invocations.length !== 1) {
- throw new Error(`expected one successful bounded query, found ${invocations.length}`);
- }
-
- const outputs = fs.readFileSync(outputsPath, "utf8");
- if (!outputs.includes('"noop"') || !outputs.includes("PASS")) {
- throw new Error("agent did not report a bounded-query PASS through noop");
- }
- NODE
----
-
-# Smoke Test: Bounded Queries
-
-Use the generated `bounded-query` skill to answer exactly one finite question about
-`github/gh-aw`: does the repository root contain a `go.mod` file?
-
-The query must:
-
-1. Use a boolean JSON schema.
-2. Run a Python script inside the bounded-query environment that checks
- `/query/repo/go.mod`.
-3. Return `true`.
-
-No GitHub API tools are available to the agent. Do not use network requests or
-the current checkout to answer the question. The test passes only when the
-bounded query succeeds and returns `true`.
-
-Call `noop` with a concise PASS result that includes the returned boolean only
-when the query returns `true`. If the skill is unavailable, call
-`safeoutputs-missing_tool`. If the query fails or returns anything other than
-`true`, call `safeoutputs-missing_data`. Never report FAIL through `noop`.
diff --git a/.github/workflows/supply-chain-scan.yml b/.github/workflows/supply-chain-scan.yml
index 0b05fcb51..60087571b 100644
--- a/.github/workflows/supply-chain-scan.yml
+++ b/.github/workflows/supply-chain-scan.yml
@@ -65,14 +65,7 @@ jobs:
- name: Compile + generate SBOMs (Syft)
env:
GH_TOKEN: ${{ github.token }}
- run: |
- # These sources require unreleased gh-aw bounded-query frontmatter.
- # Their committed lock manifests are still scanned by the steps below.
- mapfile -t workflows < <(
- find .github/workflows -maxdepth 1 -name '*.md' \
- ! -name 'smoke-bounded-queries*.md' -print
- )
- gh aw compile --syft "${workflows[@]}"
+ run: gh aw compile --syft .github/workflows/*.md
- name: Authenticate to GHCR for image scanning
run: echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
@@ -164,7 +157,7 @@ jobs:
PYEOF
- name: Build and scan PR container images (BLOCKING gate, Grype v0.116.0)
- # Builds agent, api-proxy, cli-proxy, and gh-aw-node from the Dockerfiles in
+ # Builds every production image from the Dockerfiles in
# this PR and scans them with --fail-on high. This is the BLOCKING supply-chain
# gate: it is the only scan that reflects the CVE fixes in a PR, so a
# High/Critical finding here fails the job.
@@ -176,17 +169,20 @@ jobs:
cwd = os.getcwd()
db_cache = os.environ['GRYPE_DB']
containers = [
- ('agent', 'containers/agent'),
- ('api-proxy', 'containers/api-proxy'),
- ('cli-proxy', 'containers/cli-proxy'),
- ('gh-aw-node', 'containers/gh-aw-node'),
+ ('agent', 'containers/agent', []),
+ ('api-proxy', 'containers/api-proxy', []),
+ ('cli-proxy', 'containers/cli-proxy', []),
+ ('gh-aw-node', 'containers/gh-aw-node', []),
+ ('enclave-script', 'containers', ['-f', 'containers/enclave/Dockerfile', '--target', 'enclave-script']),
+ ('enclave-agent', 'containers', ['-f', 'containers/enclave/Dockerfile', '--target', 'enclave-agent']),
+ ('enclave-mcp-server', 'containers', ['-f', 'containers/enclave/Dockerfile', '--target', 'enclave-mcp-server']),
]
rc = 0
- for name, context in containers:
+ for name, context, build_args in containers:
tag = f'awf-pr-scan-{name}:pr'
tar = f'/tmp/awf-pr-scan-{name}.tar'
print(f'::group::docker build {name}', flush=True)
- b = subprocess.run(['docker', 'build', '-t', tag, context])
+ b = subprocess.run(['docker', 'build', *build_args, '-t', tag, context])
print('::endgroup::', flush=True)
if b.returncode != 0:
print(f'::error::docker build failed for {name}', flush=True)
diff --git a/.github/workflows/test-bounded-agent-runtime-matrix.yml b/.github/workflows/test-bounded-agent-runtime-matrix.yml
deleted file mode 100644
index 2ff38534e..000000000
--- a/.github/workflows/test-bounded-agent-runtime-matrix.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-name: Bounded-Agent Runtime Matrix
-
-on:
- pull_request:
- paths:
- - 'containers/bounded-agent/**'
- - 'scripts/ci/report-bounded-agent-runtime-matrix*'
- - 'scripts/ci/probe-bounded-agent-primary-sbx.js'
- - 'scripts/ci/smoke-bounded-agent-enclave.sh'
- - 'src/bounded-agent/**'
- - '.github/workflows/test-bounded-agent-runtime-matrix.yml'
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- docker:
- name: Docker enclave matrix
- runs-on: ubuntu-latest
- timeout-minutes: 30
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: '24'
- package-manager-cache: false
- - run: npm ci
- - run: npm run build
- - name: Report all nine cells and require Docker/Docker
- run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/docker
- - name: Run live Docker enclave smoke
- run: bash scripts/ci/smoke-bounded-agent-enclave.sh docker
-
- gvisor:
- name: gVisor enclave matrix
- runs-on: ubuntu-latest
- timeout-minutes: 30
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Install registered runsc
- run: |
- set -euo pipefail
- arch="$(uname -m)"
- url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}"
- curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc"
- curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512"
- (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512)
- sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc
- sudo runsc install
- sudo systemctl restart docker
- docker info --format '{{json .Runtimes}}' | grep -F '"runsc"'
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: '24'
- package-manager-cache: false
- - run: npm ci
- - run: npm run build
- - name: Report all nine cells and require Docker/gVisor
- run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/gvisor
- - name: Run live gVisor enclave smoke
- run: bash scripts/ci/smoke-bounded-agent-enclave.sh gvisor
-
- sbx-capability:
- name: Docker Sandbox capability gate
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: '24'
- package-manager-cache: false
- - run: npm ci
- - run: npm run build
- - name: Require explicit blocked result without fallback
- run: |
- set -euo pipefail
- report="$(node scripts/ci/report-bounded-agent-runtime-matrix.js)"
- printf '%s\n' "$report"
- rows="$(printf '%s\n' "$report" | grep -cE '^\| (docker|gvisor|sbx) \|')"
- test "$rows" -eq 9
- printf '%s\n' "$report" | grep -F '| docker | sbx | BLOCKED |'
- printf '%s\n' "$report" | grep -F '| gvisor | sbx | BLOCKED |'
- printf '%s\n' "$report" | grep -F '| sbx | sbx | BLOCKED |'
diff --git a/.github/workflows/test-gvisor-compat.yml b/.github/workflows/test-gvisor-compat.yml
index b15017907..ff24c7f19 100644
--- a/.github/workflows/test-gvisor-compat.yml
+++ b/.github/workflows/test-gvisor-compat.yml
@@ -10,8 +10,8 @@ permissions:
contents: read
jobs:
- bounded-query-isolation:
- name: Bounded-query gVisor isolation
+ enclave-script-coverage:
+ name: Enclave script gVisor coverage
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -28,21 +28,70 @@ jobs:
chmod +x runsc containerd-shim-runsc-v1
sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/
sudo mkdir -p /etc/docker
- printf '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}\n' |
- sudo tee /etc/docker/daemon.json
+ cat <<'EOF' | sudo tee /etc/docker/daemon.json
+ {"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}
+ EOF
sudo systemctl restart docker
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
package-manager-cache: false
- - name: Exercise bounded-query isolation under runsc
- env:
- AWF_BOUNDED_QUERY_TEST_RUNTIME: gvisor
+ - name: Exercise current enclave gVisor unit coverage
run: |
npm ci
npm run build
- npm run test:integration -- --runInBand bounded-query-isolation.test.ts
+ npm test -- --runInBand \
+ src/services/enclave-mcp-service.test.ts \
+ src/services/enclave-agent-service.test.ts \
+ src/enclave/script-runner-spec.test.ts \
+ src/enclave/agent-runner-spec.test.ts
+ - name: Smoke-test no-network script executor under Docker and gVisor
+ run: |
+ set -euo pipefail
+ image=awf-enclave-script-smoke
+ docker build --target enclave-script \
+ -f containers/enclave/Dockerfile \
+ -t "$image" containers
+ root="$(mktemp -d)"
+ trap 'rm -rf "$root"' EXIT
+ mkdir -p "$root/seed"
+ printf 'private fixture\n' > "$root/seed/value.txt"
+ printf '' > "$root/out"
+ cat > "$root/script.py" <<'PY'
+ import pathlib
+ import socket
+
+ assert pathlib.Path("/query/repo/value.txt").read_text() == "private fixture\n"
+ try:
+ socket.create_connection(("1.1.1.1", 80), timeout=1)
+ except OSError:
+ pass
+ else:
+ raise SystemExit("network unexpectedly available")
+ pathlib.Path("/query/out").write_text("true")
+ PY
+ sudo chown 65534:65534 "$root/out"
+ for runtime in runc runsc; do
+ printf '' | sudo tee "$root/out" >/dev/null
+ sudo chown 65534:65534 "$root/out"
+ docker run --rm \
+ --runtime "$runtime" \
+ --network none \
+ --read-only \
+ --user 65534:65534 \
+ --cap-drop ALL \
+ --security-opt no-new-privileges:true \
+ --security-opt "seccomp=$(pwd)/containers/enclave/seccomp.json" \
+ --entrypoint /usr/local/bin/run-enclave-script \
+ --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \
+ --tmpfs /query:rw,nosuid,nodev,size=16m,uid=65534,gid=65534,mode=0700 \
+ -v "$root/seed:/awf/seed:ro" \
+ -v "$root/script.py:/awf/query-script.py:ro" \
+ -v "$root/out:/awf/out:rw" \
+ "$image"
+ test "$(cat "$root/out")" = true
+ done
install-gvisor:
name: Install gVisor
diff --git a/.grype.yaml b/.grype.yaml
index 335085ee1..0e1519671 100644
--- a/.grype.yaml
+++ b/.grype.yaml
@@ -34,6 +34,55 @@ ignore:
name: node
version: "22.23.2"
type: binary
+ - vulnerability: CVE-2026-58043
+ package:
+ name: node
+ version: "24.18.1"
+ type: binary
+
+ # ── CPython 3.14.7 corrected affected-version metadata ──────────────────────
+ #
+ # The Python CNA records for these findings mark 3.14.0 through 3.14.6 as
+ # affected and 3.14.7 as fixed. Grype v0.116.0 instead reports 3.14.7 as
+ # vulnerable and lists only 3.15 prereleases as fixes. Keep these exceptions
+ # scoped to the patched 3.14.7 binary and remove them after Grype corrects its
+ # affected-version ranges.
+ - vulnerability: CVE-2026-11940
+ package:
+ name: python
+ version: "3.14.7"
+ type: binary
+ - vulnerability: CVE-2026-15308
+ package:
+ name: python
+ version: "3.14.7"
+ type: binary
+ - vulnerability: CVE-2026-11972
+ package:
+ name: python
+ version: "3.14.7"
+ type: binary
+
+ # ── stdlib@go1.26.3 embedded in Alpine docker-cli ───────────────────────────
+ #
+ # GO-2026-5037 (crypto/x509 VerifyHostname quadratic processing, HIGH):
+ # A certificate with many DNS SAN entries can make hostname verification
+ # disproportionately expensive.
+ #
+ # Risk acceptance — NOT REACHABLE in this image:
+ # The enclave MCP server invokes /usr/bin/docker only through the local
+ # Docker Unix socket. It never configures a TCP/TLS Docker endpoint, so the
+ # affected x509 hostname-verification path cannot execute.
+ #
+ # No fixed Alpine package is available today: docker-cli 29.5.3-r0 is the
+ # latest alpine3.24 package and was built with Go 1.26.3. Revisit when Alpine
+ # publishes a docker-cli build using Go >= 1.26.4.
+ - vulnerability: GO-2026-5037
+ package:
+ name: stdlib
+ version: "go1.26.3"
+ type: go-module
+ location: "/usr/bin/docker"
# ── stdlib@go1.24.6 embedded in gosu binary ──────────────────────────────────
#
diff --git a/CLAUDE.md b/CLAUDE.md
index d742d7126..c6a9b9ec9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,9 +6,9 @@ This file provides guidance to coding agent when working with code in this repos
`awf` (Agentic Workflow Firewall, package `@github/awf`) is a CLI that wraps any command in a sandboxed Docker network. It provides L7 (HTTP/HTTPS) egress control using Squid proxy, restricting network access to a whitelist of approved domains while giving the agent access to the host workspace and selected system paths via chroot and selective bind mounts.
-### Three Container Components
+### Core and Optional Container Components
-The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts`. There are three containers, two of which are always required and one optional:
+The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts`. Squid, the primary agent, and the general API proxy are the baseline services; private-repository enclaves add AWF-owned optional services on top:
**1. Squid Proxy (always required)** — `containers/squid/`, IP `172.30.0.10`
- Enforces domain ACL filtering for all HTTP/HTTPS traffic
@@ -28,30 +28,15 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts
- Agent calls the sidecar with no auth (e.g., `http://172.30.0.30:10001` for Anthropic); sidecar injects the real key and forwards via Squid
- Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10003 (Gemini) — these are discrete ports, not a contiguous range
-**4. Bounded-Query Broker (optional)** — `containers/bounded-query/`, no network
-- Enabled via `boundedQueries.enabled` in the AWF config file (config-only; there is no CLI flag family)
-- The only AWF service with `network_mode: none`: no `awf-net`, no external bridge, no DNS, no Squid, no host gateway
-- Reachable only through one Unix socket in a run-specific `/var/tmp` ingress root, bind-mounted into the agent at `/run/awf-bounded-query/broker.sock`; all seeds, workspaces, maps, control state, and audits live in a disjoint broker-private `/var/tmp` root
-- Receives the resolved Docker socket so it can launch per-invocation query containers; that path never enters the agent's env or volumes
-- The broker (`bounded-query-broker`) and query sandbox (`bounded-query`) are separate published images; a one-shot networkless Compose service pulls the sandbox image before broker startup so the broker (which has no network) can launch query containers
-- Queries run `python3` with `--network none`, `--read-only`, non-root, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile, and time/memory/CPU/PID/file-size bounds
-- Agent surface: `bounded-query` command at `/tmp/awf-lib/bounded-query` (inside chroot, added to PATH by `entrypoint.sh`; the source file in the container is `/usr/local/bin/bounded-query-wrapper.sh`) plus a generated read-only `SKILL.md`; the wrapper always prints one canonical JSON line, writes nothing to stderr, and exits `0`
-- Trusted host staging (`src/bounded-query/staging.ts`) materializes an immutable seed per configured repo *before* the agent starts, using `GH_TOKEN`/`GITHUB_TOKEN` only in a child-process env — never in argv, a URL, a log, or the compose file
-- See [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 for the full model, including per-repository information-budget accounting and residual channels
-
-**5. Bounded-Agent Enclave (optional)** — `containers/bounded-agent/`, API-proxy-only network
-- Enabled via `boundedAgents.enabled` in the AWF config file (config-only; there is no CLI flag family)
-- Agentic sibling of bounded queries: a trusted broker runs a **fixed, AWF-authored model loop** in a single-use enclave instead of an agent-authored script
-- Requires `--enable-api-proxy` plus a configured `profile`/`model` route (`openai` or `anthropic`); preflight fails closed otherwise
-- Broker (`awf-bounded-agent-broker`) is `network_mode: none` and never joins the enclave network; it receives the Docker socket only to launch enclaves
-- Enclaves join **only** the dedicated `internal` `awf-bounded-agent` network (172.31.0.0/24). A dedicated API-proxy instance with private telemetry joins it at a fixed address/alias and is the enclave's only upstream egress. No primary agent, Squid, general proxy, broker, safe outputs, MCP gateway, or CLI proxy is on that network.
-- Enclaves run `--read-only` with the immutable seed bind-mounted `ro`, bounded tmpfs for work/result/`/tmp`, fixed non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds; every container is labelled `awf.bounded-agent.run=` for deterministic orphan cleanup
-- Separate private root (`/var/tmp/awf-bounded-agent-private-*`) and a **separate ledger** from bounded queries
-- Agent surface: `bounded-agent` command at `/tmp/awf-lib/bounded-agent` plus a generated read-only `SKILL.md`; accepts only `--repo`, `--schema`, and task text on stdin
-- Image build context is `containers/` (not `containers/bounded-agent/`) because the broker reuses the shared PR1 `bounded-execution` foundation and sandbox seccomp profile under `containers/bounded-query/`
-- `runtime: "sbx"` is schema-accepted but fails closed with a not-yet-implemented capability error; `gvisor` requires an exactly registered `runsc` and never downgrades
-- **Provider disclosure caveat:** repository-derived content reaches the configured model provider through the API proxy. The ledger bounds what the *calling agent* learns, not what the *provider* sees.
-- See [docs/bounded-agents.md](docs/bounded-agents.md) and [docs/awf-config-spec.md](docs/awf-config-spec.md) §15
+**4. Unified Enclaves (optional)** — `containers/enclave/`
+- Enabled via `enclaves.enabled` in the AWF config file
+- One AWF-owned MCP server (`enclave-mcp-server`) exposes enabled enclave executors only through compiler-launched `gh-aw-mcpg`; the primary agent gets no direct enclave socket, wrapper binary, capability, or private transport
+- `enclave_run_script` launches a no-network, read-only, single-use Python executor and returns one canonical JSON result
+- `enclave_run_agent` launches a single-use Copilot enclave on the dedicated `internal` `awf-enclave-agent` network whose sole peer is the dedicated API proxy; Squid, the primary agent, the general API proxy, safe outputs, and the MCP gateway are excluded
+- Script and agent executors share one trusted `enclaves.privateRepos` list, one per-run information ledger, and one AWF-owned admission lane
+- Rollout depends on the compiler handoff contract in `github/gh-aw#50920` and late backend rediscovery in `github/gh-aw-mcpg#10784`, which requires MCP Gateway spec 1.15.0 and the first mcpg release after v0.4.8 containing it
+- While the gateway backend is still coming up, AWF retries retryable HTTP `503 backend_unavailable` responses within `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS`
+- See [docs/enclaves-architecture.md](docs/enclaves-architecture.md) and [docs/awf-config-spec.md](docs/awf-config-spec.md) §14
### Documentation Files
@@ -61,8 +46,7 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts
- **[docs/logging_quickref.md](docs/logging_quickref.md)** - Quick reference for log queries and monitoring
- **[docs/releasing.md](docs/releasing.md)** - Release process and versioning instructions
- **[docs/INTEGRATION-TESTS.md](docs/INTEGRATION-TESTS.md)** - Integration test coverage guide with gap analysis
-- **[docs/bounded-queries.md](docs/bounded-queries.md)** - Bounded-query (no-network script sandbox) guide
-- **[docs/bounded-agents.md](docs/bounded-agents.md)** - Bounded-agent (API-proxy-only enclave) guide and threat model
+- **[docs/enclaves-architecture.md](docs/enclaves-architecture.md)** - Unified enclave architecture, MCP gateway handoff, migration, and coverage notes
## Development Workflow
diff --git a/README.md b/README.md
index eedb8bdad..4f3d3199c 100644
--- a/README.md
+++ b/README.md
@@ -74,8 +74,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su
- [Usage guide](docs/usage.md) — CLI flags, domain allowlists, examples
- [AWF config schema](docs/awf-config.schema.json) — machine-readable JSON Schema for JSON/YAML configs (also published as a [versioned release asset](https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json) for IDE autocomplete)
- [AWF config spec](docs/awf-config-spec.md) — normative processing and precedence rules for tooling/compiler integration
-- [Bounded queries](docs/bounded-queries.md) — run information-budgeted queries against private repositories without exposing their contents
-- [Bounded agents](docs/bounded-agents.md) — delegate finite-schema repository analysis to API-proxy-only Docker or gVisor enclaves
+- [Unified enclave architecture](docs/enclaves-architecture.md) — AWF-owned enclave MCP server, mcpg-only access, and the `enclave_run_script` / `enclave_run_agent` tools for private-repository execution
- [Audit log schema](schemas/audit.schema.json) — JSON Schema for L7 traffic audit records (`audit.jsonl`)
- [Token usage schema](schemas/token-usage.schema.json) — JSON Schema for per-call token usage records (`token-usage.jsonl`)
- [Schemas README](schemas/README.md) — versioning policy, record identification, and validation examples
diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile
index f0fd3670a..735e34190 100644
--- a/containers/agent/Dockerfile
+++ b/containers/agent/Dockerfile
@@ -272,18 +272,14 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \
chown -R awfuser:awfuser /home/awfuser
# Copy iptables setup script, PID logger, API proxy health check, Claude key helper,
-# gh CLI proxy wrapper (used when --enable-cli-proxy is active), and the
-# bounded-query wrapper (installed as `bounded-query` when bounded queries are enabled),
-# and the bounded-agent wrapper (installed as `bounded-agent` when bounded agents are enabled)
+# gh CLI proxy wrapper (used when --enable-cli-proxy is active)
COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY pid-logger.sh /usr/local/bin/pid-logger.sh
COPY api-proxy-health-check.sh /usr/local/bin/api-proxy-health-check.sh
COPY get-claude-key.sh /usr/local/bin/get-claude-key.sh
COPY gh-cli-proxy-wrapper.sh /usr/local/bin/gh-cli-proxy-wrapper.sh
-COPY bounded-query-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh
-COPY bounded-agent-wrapper.sh /usr/local/bin/bounded-agent-wrapper.sh
-RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh /usr/local/bin/bounded-agent-wrapper.sh
+RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh
# Copy pre-built one-shot-token library from rust-builder stage
# This prevents tokens from being read multiple times (e.g., by malicious code)
diff --git a/containers/agent/bounded-agent-wrapper.sh b/containers/agent/bounded-agent-wrapper.sh
deleted file mode 100644
index 1c7373176..000000000
--- a/containers/agent/bounded-agent-wrapper.sh
+++ /dev/null
@@ -1,152 +0,0 @@
-#!/bin/sh
-# /usr/local/bin/bounded-agent
-#
-# Agent-facing bounded-agent CLI (protocol v1).
-#
-# Forwards a *narrow* request to the trusted bounded-agent broker over either
-# the Compose Unix socket or the authenticated sbx HTTP ingress. Like
-# bounded-query-wrapper.sh, the API is deliberately
-# far narrower than a general tool: this wrapper cannot express a command, an
-# image, an executable, a path, a URL, a ref, a mount, an environment variable,
-# an endpoint, a network, a proxy, a credential, a runtime, a timeout, a
-# resource limit, a model, a provider, a system prompt, or a tool definition.
-# It accepts exactly:
-#
-# --repo owner/repo (exactly once)
-# --schema '' (exactly once; a finite response schema, see
-# src/bounded-execution/finite-disclosure.ts)
-# the bounded task text on stdin
-#
-# Output contract: exactly one line of canonical JSON on stdout, nothing on
-# stderr, and exit status 0 — for every outcome and for every failure.
-# Transport, framing, and validation failures all produce the same local
-# {"status":"error"} so the agent cannot distinguish them by exit status. The
-# remaining information budget is never disclosed.
-#
-# Dependencies: curl, base64 (both already required/available in the agent
-# image).
-
-CANONICAL_ERROR='{"status":"error"}'
-SOCKET="${AWF_BOUNDED_AGENT_SOCKET:-}"
-ENDPOINT="${AWF_BOUNDED_AGENT_ENDPOINT:-}"
-CAPABILITY="${AWF_BOUNDED_AGENT_CAPABILITY:-}"
-PROTOCOL_VERSION=1
-# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-execution/finite-disclosure.ts
-# and containers/bounded-query/bounded-execution/finite-disclosure.js.
-MAX_SCHEMA_BYTES=4096
-
-emit_error() {
- printf '%s\n' "$CANONICAL_ERROR"
- exit 0
-}
-
-REPO=""
-SCHEMA=""
-HAVE_REPO=0
-HAVE_SCHEMA=0
-
-while [ $# -gt 0 ]; do
- case "$1" in
- --repo)
- [ $# -ge 2 ] || emit_error
- [ "$HAVE_REPO" -eq 0 ] || emit_error
- REPO="$2"
- HAVE_REPO=1
- shift 2
- ;;
- --schema)
- [ $# -ge 2 ] || emit_error
- [ "$HAVE_SCHEMA" -eq 0 ] || emit_error
- SCHEMA="$2"
- HAVE_SCHEMA=1
- shift 2
- ;;
- *)
- # Any other flag, any `--flag=value` form, and any positional argument
- # is an unsupported control.
- emit_error
- ;;
- esac
-done
-
-[ "$HAVE_REPO" -eq 1 ] || emit_error
-[ "$HAVE_SCHEMA" -eq 1 ] || emit_error
-
-printf '%s' "$REPO" | LC_ALL=C grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$' || emit_error
-case "$REPO" in
- *..* ) emit_error ;;
-esac
-
-[ -n "$SCHEMA" ] || emit_error
-[ "$(printf '%s' "$SCHEMA" | wc -c)" -le "$MAX_SCHEMA_BYTES" ] || emit_error
-
-# base64url, no padding: standard base64 with `+/` -> `-_`, `=` stripped, and
-# newlines removed (wrapping width varies across base64 implementations).
-SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') || emit_error
-
-# The task must arrive on stdin; an interactive terminal means no task.
-[ ! -t 0 ] || emit_error
-
-if [ -n "$SOCKET" ] && [ -z "$ENDPOINT" ] && [ -z "$CAPABILITY" ]; then
- [ -S "$SOCKET" ] || emit_error
- RESPONSE=$(
- curl --silent --show-error \
- --noproxy '*' \
- --unix-socket "$SOCKET" \
- --max-time 660 \
- -X POST \
- -H "Expect:" \
- -H "Content-Type: application/octet-stream" \
- -H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \
- -H "X-AWF-Repo: ${REPO}" \
- -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
- --data-binary @- \
- "http://localhost/query" 2>/dev/null
- ) || emit_error
-elif [ -z "$SOCKET" ] && [ -n "$ENDPOINT" ] && [ -n "$CAPABILITY" ]; then
- case "$ENDPOINT" in
- http://host.docker.internal:*/query)
- PORT="${ENDPOINT#http://host.docker.internal:}"
- PORT="${PORT%/query}"
- printf '%s' "$PORT" | LC_ALL=C grep -Eq '^[0-9]{1,5}$' || emit_error
- [ "$PORT" -ge 1 ] 2>/dev/null || emit_error
- [ "$PORT" -le 65535 ] 2>/dev/null || emit_error
- ;;
- *) emit_error ;;
- esac
- printf '%s' "$CAPABILITY" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || emit_error
- RESPONSE=$(
- curl --silent --show-error \
- --noproxy '*' \
- --max-time 660 \
- -X POST \
- -H "Expect:" \
- -H "Content-Type: application/octet-stream" \
- -H "X-AWF-Capability: ${CAPABILITY}" \
- -H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \
- -H "X-AWF-Repo: ${REPO}" \
- -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
- --data-binary @- \
- "$ENDPOINT" 2>/dev/null
- ) || emit_error
-else
- emit_error
-fi
-
-# Pass the broker's canonical response through unmodified, but only if it has
-# one of the two shapes the protocol ever produces. Anything else (a dead or
-# misbehaving broker, a transport-level fragment) is treated as a failure
-# rather than forwarded verbatim.
-case "$RESPONSE" in
- '{"status":"error"}')
- printf '%s\n' "$RESPONSE"
- exit 0
- ;;
- '{"status":"ok","result":'*'}')
- printf '%s\n' "$RESPONSE"
- exit 0
- ;;
- *)
- emit_error
- ;;
-esac
diff --git a/containers/agent/bounded-query-wrapper.sh b/containers/agent/bounded-query-wrapper.sh
deleted file mode 100755
index 57b832f8a..000000000
--- a/containers/agent/bounded-query-wrapper.sh
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/bin/sh
-# /usr/local/bin/bounded-query
-#
-# Agent-facing bounded-query CLI (protocol v2).
-#
-# Forwards a *narrow* request to the trusted bounded-query broker over either
-# the Compose Unix socket or the authenticated sbx HTTP ingress. It is
-# analogous to gh-cli-proxy-wrapper.sh, but the
-# API is deliberately far narrower: this wrapper cannot express a command, an
-# image, a path, a URL, a ref, a mount, a runtime, a timeout, an environment,
-# or a credential. It accepts exactly:
-#
-# --repo owner/repo (exactly once)
-# --schema '' (exactly once; a finite response schema, see
-# src/bounded-execution/finite-disclosure.ts)
-# the query script on stdin
-#
-# Output contract: exactly one line of canonical JSON on stdout, nothing on
-# stderr, and exit status 0 — for every outcome and for every failure.
-# Transport, framing, and validation failures all produce the same local
-# {"status":"error"} so the agent cannot distinguish them by exit status.
-#
-# The wrapper does not (and cannot, in POSIX sh) validate the schema's
-# structure, cardinality, or information-budget charge — that is the trusted
-# broker's job, enforced *before* it copies a seed or launches Python. The
-# wrapper's only responsibilities are: enforce the fixed CLI shape, transport
-# the request unmodified, and pass the broker's response through unmodified.
-#
-# Dependencies: curl, base64 (both already required/available in the agent
-# image).
-
-CANONICAL_ERROR='{"status":"error"}'
-SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-}"
-ENDPOINT="${AWF_BOUNDED_QUERY_ENDPOINT:-}"
-CAPABILITY="${AWF_BOUNDED_QUERY_CAPABILITY:-}"
-PROTOCOL_VERSION=2
-# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-execution/finite-disclosure.ts
-# and containers/bounded-query/bounded-execution/finite-disclosure.js.
-MAX_SCHEMA_BYTES=4096
-
-emit_error() {
- printf '%s\n' "$CANONICAL_ERROR"
- exit 0
-}
-
-REPO=""
-SCHEMA=""
-HAVE_REPO=0
-HAVE_SCHEMA=0
-
-while [ $# -gt 0 ]; do
- case "$1" in
- --repo)
- [ $# -ge 2 ] || emit_error
- [ "$HAVE_REPO" -eq 0 ] || emit_error
- REPO="$2"
- HAVE_REPO=1
- shift 2
- ;;
- --schema)
- [ $# -ge 2 ] || emit_error
- [ "$HAVE_SCHEMA" -eq 0 ] || emit_error
- SCHEMA="$2"
- HAVE_SCHEMA=1
- shift 2
- ;;
- *)
- # Any other flag, any `--flag=value` form, and any positional argument
- # is an unsupported control.
- emit_error
- ;;
- esac
-done
-
-[ "$HAVE_REPO" -eq 1 ] || emit_error
-[ "$HAVE_SCHEMA" -eq 1 ] || emit_error
-
-printf '%s' "$REPO" | LC_ALL=C grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$' || emit_error
-case "$REPO" in
- *..* ) emit_error ;;
-esac
-
-[ -n "$SCHEMA" ] || emit_error
-[ "$(printf '%s' "$SCHEMA" | wc -c)" -le "$MAX_SCHEMA_BYTES" ] || emit_error
-
-# base64url, no padding: standard base64 with `+/` -> `-_`, `=` stripped, and
-# newlines removed (wrapping width varies across base64 implementations).
-SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') || emit_error
-
-# The script must arrive on stdin; an interactive terminal means no script.
-[ ! -t 0 ] || emit_error
-
-if [ -n "$SOCKET" ] && [ -z "$ENDPOINT" ] && [ -z "$CAPABILITY" ]; then
- [ -S "$SOCKET" ] || emit_error
- RESPONSE=$(
- curl --silent --show-error \
- --noproxy '*' \
- --unix-socket "$SOCKET" \
- --max-time 660 \
- -X POST \
- -H "Expect:" \
- -H "Content-Type: application/octet-stream" \
- -H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \
- -H "X-AWF-Repo: ${REPO}" \
- -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
- --data-binary @- \
- "http://localhost/query" 2>/dev/null
- ) || emit_error
-elif [ -z "$SOCKET" ] && [ -n "$ENDPOINT" ] && [ -n "$CAPABILITY" ]; then
- case "$ENDPOINT" in
- http://host.docker.internal:*/query)
- PORT="${ENDPOINT#http://host.docker.internal:}"
- PORT="${PORT%/query}"
- printf '%s' "$PORT" | LC_ALL=C grep -Eq '^[0-9]{1,5}$' || emit_error
- [ "$PORT" -ge 1 ] 2>/dev/null || emit_error
- [ "$PORT" -le 65535 ] 2>/dev/null || emit_error
- ;;
- *) emit_error ;;
- esac
- printf '%s' "$CAPABILITY" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || emit_error
- RESPONSE=$(
- curl --silent --show-error \
- --noproxy '*' \
- --max-time 660 \
- -X POST \
- -H "Expect:" \
- -H "Content-Type: application/octet-stream" \
- -H "X-AWF-Capability: ${CAPABILITY}" \
- -H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \
- -H "X-AWF-Repo: ${REPO}" \
- -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
- --data-binary @- \
- "$ENDPOINT" 2>/dev/null
- ) || emit_error
-else
- emit_error
-fi
-
-# Pass the broker's canonical response through unmodified, but only if it has
-# one of the two shapes the protocol ever produces. Anything else (a dead or
-# misbehaving broker, a transport-level fragment) is treated as a failure
-# rather than forwarded verbatim.
-case "$RESPONSE" in
- '{"status":"error"}')
- printf '%s\n' "$RESPONSE"
- exit 0
- ;;
- '{"status":"ok","result":'*'}')
- printf '%s\n' "$RESPONSE"
- exit 0
- ;;
- *)
- emit_error
- ;;
-esac
diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh
index 08055b298..7ecfdd2e2 100644
--- a/containers/agent/entrypoint.sh
+++ b/containers/agent/entrypoint.sh
@@ -647,68 +647,6 @@ copy_agent_helper_scripts() {
fi
fi
- # Activate the bounded-query CLI when the bounded-query broker socket is present.
- # The wrapper is copied to /tmp/awf-lib/bounded-query so it resolves inside the
- # chroot on the same PATH entry used for the gh wrapper.
- if [ -n "$AWF_BOUNDED_QUERY_SOCKET" ] && [ -f /usr/local/bin/bounded-query-wrapper.sh ]; then
- if mkdir -p /host/tmp/awf-lib 2>/dev/null; then
- if cp /usr/local/bin/bounded-query-wrapper.sh /host/tmp/awf-lib/bounded-query 2>/dev/null && \
- chmod +x /host/tmp/awf-lib/bounded-query 2>/dev/null; then
- echo "[entrypoint] bounded-query CLI installed at /tmp/awf-lib/bounded-query (inside chroot)"
- case ":${AWF_HOST_PATH:-$PATH}:" in
- *":/tmp/awf-lib:"*) ;;
- *) export AWF_HOST_PATH="/tmp/awf-lib:${AWF_HOST_PATH:-$PATH}" ;;
- esac
- else
- echo "[entrypoint][WARN] Could not install bounded-query CLI"
- fi
- fi
- fi
-
- # Install the bounded-query SKILL.md at the standard GitHub Copilot skill
- # discovery path so agents find it via the same scan that discovers other
- # skills in ~/.github/skills/. The source file is bind-mounted read-only
- # from the host; we copy it into the chroot home so it is discovered inside
- # the chroot without leaving host state modified.
- if [ -n "$AWF_BOUNDED_QUERY_SKILL" ] && [ -f "$AWF_BOUNDED_QUERY_SKILL" ] && \
- [ -n "$SYNTH_HOME" ]; then
- SKILL_DEST_DIR="/host${SYNTH_HOME}/.github/skills/bounded-query"
- if mkdir -p "$SKILL_DEST_DIR" 2>/dev/null && \
- cp "$AWF_BOUNDED_QUERY_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then
- echo "[entrypoint] bounded-query SKILL.md installed at ${SYNTH_HOME}/.github/skills/bounded-query/SKILL.md (inside chroot)"
- else
- echo "[entrypoint][WARN] Could not install bounded-query SKILL.md"
- fi
- fi
-
- # Activate the bounded-agent CLI when the bounded-agent broker socket is
- # present. Same mechanism and PATH entry as the bounded-query CLI.
- if [ -n "$AWF_BOUNDED_AGENT_SOCKET" ] && [ -f /usr/local/bin/bounded-agent-wrapper.sh ]; then
- if mkdir -p /host/tmp/awf-lib 2>/dev/null; then
- if cp /usr/local/bin/bounded-agent-wrapper.sh /host/tmp/awf-lib/bounded-agent 2>/dev/null && \
- chmod +x /host/tmp/awf-lib/bounded-agent 2>/dev/null; then
- echo "[entrypoint] bounded-agent CLI installed at /tmp/awf-lib/bounded-agent (inside chroot)"
- case ":${AWF_HOST_PATH:-$PATH}:" in
- *":/tmp/awf-lib:"*) ;;
- *) export AWF_HOST_PATH="/tmp/awf-lib:${AWF_HOST_PATH:-$PATH}" ;;
- esac
- else
- echo "[entrypoint][WARN] Could not install bounded-agent CLI"
- fi
- fi
- fi
-
- # Install the bounded-agent SKILL.md alongside the bounded-query one.
- if [ -n "$AWF_BOUNDED_AGENT_SKILL" ] && [ -f "$AWF_BOUNDED_AGENT_SKILL" ] && \
- [ -n "$SYNTH_HOME" ]; then
- AGENT_SKILL_DEST_DIR="/host${SYNTH_HOME}/.github/skills/bounded-agent"
- if mkdir -p "$AGENT_SKILL_DEST_DIR" 2>/dev/null && \
- cp "$AWF_BOUNDED_AGENT_SKILL" "$AGENT_SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then
- echo "[entrypoint] bounded-agent SKILL.md installed at ${SYNTH_HOME}/.github/skills/bounded-agent/SKILL.md (inside chroot)"
- else
- echo "[entrypoint][WARN] Could not install bounded-agent SKILL.md"
- fi
- fi
}
copy_dind_runner_binary() {
@@ -1427,60 +1365,6 @@ run_non_chroot_command() {
fi
fi
- # Activate the bounded-query CLI in non-chroot mode.
- if [ -n "$AWF_BOUNDED_QUERY_SOCKET" ] && [ -f /usr/local/bin/bounded-query-wrapper.sh ]; then
- mkdir -p /tmp/awf-lib
- if cp /usr/local/bin/bounded-query-wrapper.sh /tmp/awf-lib/bounded-query 2>/dev/null && \
- chmod +x /tmp/awf-lib/bounded-query 2>/dev/null; then
- case ":${PATH}:" in
- *":/tmp/awf-lib:"*) ;;
- *) export PATH="/tmp/awf-lib:${PATH}" ;;
- esac
- echo "[entrypoint] bounded-query CLI installed at /tmp/awf-lib/bounded-query"
- else
- echo "[entrypoint][WARN] Could not install bounded-query CLI"
- fi
- fi
-
- # Install the bounded-query SKILL.md at the standard GitHub Copilot skill
- # discovery path so agents find it via the same scan that discovers other
- # skills in ~/.github/skills/ (non-chroot mode).
- if [ -n "$AWF_BOUNDED_QUERY_SKILL" ] && [ -f "$AWF_BOUNDED_QUERY_SKILL" ]; then
- SKILL_DEST_DIR="${HOME}/.github/skills/bounded-query"
- if mkdir -p "$SKILL_DEST_DIR" 2>/dev/null && \
- cp "$AWF_BOUNDED_QUERY_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then
- echo "[entrypoint] bounded-query SKILL.md installed at ${SKILL_DEST_DIR}/SKILL.md"
- else
- echo "[entrypoint][WARN] Could not install bounded-query SKILL.md"
- fi
- fi
-
- # Activate the bounded-agent CLI in non-chroot mode.
- if [ -n "$AWF_BOUNDED_AGENT_SOCKET" ] && [ -f /usr/local/bin/bounded-agent-wrapper.sh ]; then
- mkdir -p /tmp/awf-lib
- if cp /usr/local/bin/bounded-agent-wrapper.sh /tmp/awf-lib/bounded-agent 2>/dev/null && \
- chmod +x /tmp/awf-lib/bounded-agent 2>/dev/null; then
- case ":${PATH}:" in
- *":/tmp/awf-lib:"*) ;;
- *) export PATH="/tmp/awf-lib:${PATH}" ;;
- esac
- echo "[entrypoint] bounded-agent CLI installed at /tmp/awf-lib/bounded-agent"
- else
- echo "[entrypoint][WARN] Could not install bounded-agent CLI"
- fi
- fi
-
- # Install the bounded-agent SKILL.md (non-chroot mode).
- if [ -n "$AWF_BOUNDED_AGENT_SKILL" ] && [ -f "$AWF_BOUNDED_AGENT_SKILL" ]; then
- AGENT_SKILL_DEST_DIR="${HOME}/.github/skills/bounded-agent"
- if mkdir -p "$AGENT_SKILL_DEST_DIR" 2>/dev/null && \
- cp "$AWF_BOUNDED_AGENT_SKILL" "$AGENT_SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then
- echo "[entrypoint] bounded-agent SKILL.md installed at ${AGENT_SKILL_DEST_DIR}/SKILL.md"
- else
- echo "[entrypoint][WARN] Could not install bounded-agent SKILL.md"
- fi
- fi
-
# This prevents malicious code from modifying iptables rules or using chroot
# Security note: capsh --drop removes capabilities from the bounding set,
# preventing any process (even if it escalates to root) from acquiring them
diff --git a/containers/bounded-agent/Dockerfile b/containers/bounded-agent/Dockerfile
deleted file mode 100644
index 97a65279b..000000000
--- a/containers/bounded-agent/Dockerfile
+++ /dev/null
@@ -1,93 +0,0 @@
-# Standard bounded-agent enclave and broker images:
-#
-# `enclave` is published as `bounded-agent:*` with the pinned Copilot CLI.
-# `broker` is published as `bounded-agent-broker:*`.
-# Has Node + docker-cli to run the server and launch enclave containers.
-# It runs with `network_mode: none` and is never a member of the enclave
-# network.
-#
-# BUILD CONTEXT: `containers/` (not `containers/bounded-agent/`). The broker
-# reuses the PR1 bounded-execution foundation and the audited sandbox seccomp
-# profile that already live under `containers/bounded-query/`, and a wider
-# context is preferred over duplicating a security-critical implementation.
-#
-# docker build -f bounded-agent/Dockerfile --target broker containers/
-
-# ──────────────────────────────────────────────────────────────────────────
-# Standard self-contained bounded-agent image.
-# ──────────────────────────────────────────────────────────────────────────
-FROM node:24-bookworm-slim AS enclave
-
-ARG COPILOT_CLI_VERSION=1.0.34
-# The optional standalone launcher exits silently under the enclave seccomp
-# profile. Point the executable directly at the pinned Node.js entrypoint so
-# npm-loader cannot resolve or execute that launcher.
-RUN apt-get update \
- && apt-get install -y --no-install-recommends bash ca-certificates git python3 ripgrep \
- && test -r /etc/ssl/certs/ca-certificates.crt \
- && npm_config_ignore_scripts=false npm install -g "@github/copilot@${COPILOT_CLI_VERSION}" \
- && rm -rf /usr/local/lib/node_modules/@github/copilot-linux-* \
- && ln -sf ../lib/node_modules/@github/copilot/index.js /usr/local/bin/copilot \
- && node --version | grep -qE '^v24\.' \
- && copilot --version | grep -q "GitHub Copilot CLI ${COPILOT_CLI_VERSION}" \
- && rm -rf /var/lib/apt/lists/* \
- && rm -f /usr/bin/apt /usr/bin/apt-get /usr/bin/apt-cache \
- /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
-
-COPY bounded-agent/copilot-entrypoint.py /usr/local/bin/run-bounded-agent
-RUN chmod 0555 /usr/local/bin/run-bounded-agent \
- && python3 -m py_compile /usr/local/bin/run-bounded-agent \
- && rm -rf /usr/local/bin/__pycache__ \
- && mkdir -p /agent /awf/seed
-
-# ──────────────────────────────────────────────────────────────────────────
-# broker stage: trusted broker with Node + docker-cli (default build target)
-# ──────────────────────────────────────────────────────────────────────────
-FROM node:22.23.2-alpine3.24 AS broker
-
-# docker-cli — used by the broker to launch enclave containers
-RUN apk add --no-cache docker-cli \
- && test -x /usr/bin/docker
-
-WORKDIR /opt/awf/broker
-COPY bounded-agent/broker/ /opt/awf/broker/
-# Shared PR1 bounded-execution foundation, reused verbatim rather than copied
-# into a second source tree.
-COPY bounded-query/bounded-execution/ /opt/awf/bounded-execution/
-# The audited no-network sandbox seccomp profile is reused for the enclave.
-COPY bounded-query/query-seccomp.json /opt/awf/enclave-seccomp.json
-
-RUN chmod -R a-w /opt/awf \
- && node --check /opt/awf/broker/server.js \
- && node --check /opt/awf/broker/broker.js \
- && node --check /opt/awf/broker/config.js \
- && node --check /opt/awf/broker/framing.js \
- && node --check /opt/awf/broker/protocol.js \
- && node --check /opt/awf/broker/workspace.js \
- && node --check /opt/awf/broker/enclave-runner.js \
- && node --check /opt/awf/broker/enclave-runner-spec.js \
- && node --check /opt/awf/broker/docker-client.js \
- && node --check /opt/awf/broker/docker-enclave-runner.js \
- && node --check /opt/awf/broker/gvisor-enclave-runner.js \
- && node --check /opt/awf/broker/sbx-client.js \
- && node --check /opt/awf/broker/sbx-capability-probe.js \
- && node --check /opt/awf/broker/sbx-enclave-runner-spec.js \
- && node --check /opt/awf/broker/sbx-enclave-runner.js \
- && node --check /opt/awf/broker/runtime-telemetry.js \
- && node --check /opt/awf/broker/healthcheck.js \
- && node --check /opt/awf/bounded-execution/finite-disclosure.js \
- && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \
- && node --check /opt/awf/bounded-execution/fixed-timing.js \
- && node --check /opt/awf/bounded-execution/protected-audit.js \
- && node --check /opt/awf/bounded-execution/repository-staging.js \
- && node --check /opt/awf/bounded-execution/index.js
-
-# Fixed broker-only mount points.
-RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-bounded-agent /run/awf-bounded-agent-control /var/log/awf-bounded-agent
-
-# The broker is root only so it can hand the pre-created result file to the
-# unprivileged enclave uid. Keep the default capability set dropped and restore
-# only those filesystem duties in compose.
-USER root
-
-ENTRYPOINT ["node", "/opt/awf/broker/server.js"]
diff --git a/containers/bounded-agent/bounded-execution/finite-disclosure.js b/containers/bounded-agent/bounded-execution/finite-disclosure.js
deleted file mode 100644
index ec46c5fc1..000000000
--- a/containers/bounded-agent/bounded-execution/finite-disclosure.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/finite-disclosure` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/finite-disclosure');
diff --git a/containers/bounded-agent/bounded-execution/fixed-timing.js b/containers/bounded-agent/bounded-execution/fixed-timing.js
deleted file mode 100644
index 17f221bc2..000000000
--- a/containers/bounded-agent/bounded-execution/fixed-timing.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/fixed-timing` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/fixed-timing');
diff --git a/containers/bounded-agent/bounded-execution/index.js b/containers/bounded-agent/bounded-execution/index.js
deleted file mode 100644
index ce9cd4de3..000000000
--- a/containers/bounded-agent/bounded-execution/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/index` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/index');
diff --git a/containers/bounded-agent/bounded-execution/protected-audit.js b/containers/bounded-agent/bounded-execution/protected-audit.js
deleted file mode 100644
index 88eab3776..000000000
--- a/containers/bounded-agent/bounded-execution/protected-audit.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/protected-audit` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/protected-audit');
diff --git a/containers/bounded-agent/bounded-execution/repository-staging.js b/containers/bounded-agent/bounded-execution/repository-staging.js
deleted file mode 100644
index e468b5f2e..000000000
--- a/containers/bounded-agent/bounded-execution/repository-staging.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/repository-staging` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/repository-staging');
diff --git a/containers/bounded-agent/bounded-execution/sensitivity-ledger.js b/containers/bounded-agent/bounded-execution/sensitivity-ledger.js
deleted file mode 100644
index 77760ebc8..000000000
--- a/containers/bounded-agent/bounded-execution/sensitivity-ledger.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/sensitivity-ledger` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/sensitivity-ledger');
diff --git a/containers/bounded-agent/bounded-execution/sensitivity-policy.js b/containers/bounded-agent/bounded-execution/sensitivity-policy.js
deleted file mode 100644
index a97601d15..000000000
--- a/containers/bounded-agent/bounded-execution/sensitivity-policy.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the bounded-agent image.
-//
-// The published broker image receives the real PR1 bounded-execution
-// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile,
-// which COPYs containers/bounded-query/bounded-execution/ there). This file
-// exists only so the same `../bounded-execution/sensitivity-policy` specifier
-// also resolves when the broker modules are required directly from the source
-// tree (unit tests, `node --check`), without duplicating a security-critical
-// implementation into a second directory.
-module.exports = require('../../bounded-query/bounded-execution/sensitivity-policy');
diff --git a/containers/bounded-agent/broker/audit.js b/containers/bounded-agent/broker/audit.js
deleted file mode 100644
index 183c7083b..000000000
--- a/containers/bounded-agent/broker/audit.js
+++ /dev/null
@@ -1,19 +0,0 @@
-'use strict';
-
-const { createAuditLog: createProtectedAuditLog } = require('../bounded-execution/protected-audit');
-
-/** Filename of the bounded-agent protected audit log. */
-const BOUNDED_AGENT_AUDIT_FILENAME = 'bounded-agent.jsonl';
-
-/**
- * Protected bounded-agent diagnostics.
- *
- * Uses the shared PR1 protected-audit primitive with a bounded-agent-specific
- * filename so the two subsystems' audit trails are never confused, even though
- * they already live in disjoint broker-private roots.
- */
-function createAuditLog(auditDir) {
- return createProtectedAuditLog(auditDir, BOUNDED_AGENT_AUDIT_FILENAME);
-}
-
-module.exports = { BOUNDED_AGENT_AUDIT_FILENAME, createAuditLog };
diff --git a/containers/bounded-agent/broker/broker.js b/containers/bounded-agent/broker/broker.js
deleted file mode 100644
index 2f32f0305..000000000
--- a/containers/bounded-agent/broker/broker.js
+++ /dev/null
@@ -1,296 +0,0 @@
-'use strict';
-
-const crypto = require('crypto');
-const {
- CANONICAL_ERROR_JSON,
- canonicalOkJson,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
-} = require('./protocol');
-const { validateBoundedAgentRequest } = require('./framing');
-const { createLedger } = require('./ledger');
-const { createRealClock, waitForBucket } = require('./scheduler');
-const defaultWorkspace = require('./workspace');
-
-const ENCLAVE_EXIT_CATEGORIES = Object.freeze({
- 10: 'enclave-configuration-invalid',
- 11: 'enclave-input-invalid',
- 20: 'enclave-deadline-exceeded',
- 21: 'enclave-provider-http-error',
- 22: 'enclave-provider-transport-error',
- 23: 'enclave-provider-response-invalid',
- 24: 'enclave-engine-failed',
- 30: 'enclave-result-write-failed',
- 31: 'enclave-model-loop-exhausted',
-});
-
-/**
- * The trusted bounded-agent broker.
- *
- * Responsibilities, in order, for every request:
- *
- * 1. consume one unit of the per-run *invocation* budget (`maxInvocations`,
- * an operational cap independent of the bits below) — atomically, since
- * Node's single-threaded event loop makes the check-and-increment
- * indivisible because there is no `await` between them;
- * 2. validate the request — repository selector, finite response schema, and
- * byte-bounded task text — against the fixed protocol, rejecting every
- * unknown or forbidden control, *before* anything is created;
- * 3. compute that schema's maximum complete-transcript information charge
- * (status bit + schema bits + timing bits) and atomically debit it from
- * the repository's per-run bit ledger **before** any workspace is
- * materialized or any container is launched;
- * 4. map the normalized repo id through AWF's static seed map to an opaque,
- * immutable seed the caller never sees or names;
- * 5. launch a fresh, uniquely named, labelled enclave with a fixed argument
- * vector on the dedicated bounded-agent network;
- * 6. strictly validate the enclave's dedicated bounded result file against
- * the approved schema and canonically re-serialize it — raw enclave bytes,
- * stdout, stderr, transcript, and exit status never reach the caller;
- * 7. destroy the private workspace, then respond at the first timing bucket
- * boundary at or after all secret-dependent processing completed.
- *
- * Every failure at every step produces the identical canonical
- * `{"status":"error"}`. The reason is recorded in the protected audit log,
- * which is never mounted into the agent or an enclave — and even there, the
- * repository, the task, the transcript, the raw result, host paths, tokens,
- * and provider payloads are never recorded.
- *
- * Invocations are serialized. That bounds concurrent resource use and removes
- * any cross-invocation race in workspace creation/teardown/ledger access.
- */
-
-function createBroker(params) {
- const { config, seedMap, runId, audit } = params;
- const workspace = params.workspace || defaultWorkspace;
- if (!params.runner) {
- throw new Error('createBroker requires a trusted EnclaveRunner');
- }
- const runner = params.runner;
- const clock = params.clock || createRealClock();
- // A ledger built from this broker's own seed map. Bounded agents never share
- // a ledger with bounded queries: the two brokers are separate processes with
- // separate seed maps and separate private roots.
- const ledger = params.ledger || createLedger(seedMap);
- const telemetry = params.telemetry || { emit() {} };
-
- let invocationsUsed = 0;
- let tail = Promise.resolve();
- let accepting = true;
-
- function emitInvocationTelemetry(category) {
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- boundedAgentBackend: config.backend,
- lifecycleClass: 'invocation',
- capabilityState: 'supported',
- category,
- });
- }
-
- async function execute(request, respond) {
- const invocationId = crypto.randomBytes(12).toString('hex');
- let responded = false;
- const safeRespond = (json) => {
- if (responded) return;
- responded = true;
- respond(json);
- };
-
- const validation = validateBoundedAgentRequest(request, { maxTaskBytes: config.maxTaskBytes });
- if (!validation.valid) {
- audit.failure(invocationId, 'invalid-request', validation.errors.join('; '));
- emitInvocationTelemetry('invalid-request');
- safeRespond(CANONICAL_ERROR_JSON);
- return;
- }
- const { privateRepo, schema, task } = validation.request;
- const repoKey = privateRepo.toLowerCase();
-
- const seed = seedMap.get(repoKey);
- if (!seed) {
- // Deliberately does not record which repository was requested.
- audit.failure(invocationId, 'repo-not-allowed');
- emitInvocationTelemetry('repo-not-allowed');
- safeRespond(CANONICAL_ERROR_JSON);
- return;
- }
-
- // Compute and debit the charge for THIS invocation's schema *before*
- // creating a workspace or launching an enclave. The charge covers the
- // status and timing channels as well as the schema payload.
- const charge = queryBitsForSchema(schema);
- if (!ledger.tryDebit(repoKey, charge)) {
- audit.failure(invocationId, 'bit-budget-exhausted', `charge=${charge}`);
- emitInvocationTelemetry('bit-budget-exhausted');
- safeRespond(CANONICAL_ERROR_JSON);
- return;
- }
-
- // From here on the charge is committed (never refunded) and every response
- // must be time-bucketed: enclave execution runs against secret repository
- // content, so its latency alone is a signal.
- const startMs = clock.nowMs();
-
- let layout;
- let failureReason;
- let canonicalResult;
-
- try {
- layout = workspace.createInvocationWorkspace({ config, invocationId, task, schema });
- } catch (error) {
- failureReason = ['workspace-create-failed', error.message];
- }
-
- if (layout) {
- const remainingMs = config.timeoutSeconds * 1000 - (clock.nowMs() - startMs);
- if (remainingMs <= 0) {
- failureReason = ['timeout', 'workspace-creation-overran-deadline'];
- } else {
- try {
- const run = await runner.runEnclaveContainer({
- config,
- runId,
- invocationId,
- seedId: seed.seedId,
- timeoutMs: remainingMs,
- });
- if (run.timedOut) {
- failureReason = ['timeout'];
- } else if (run.exitCode !== 0) {
- failureReason = [ENCLAVE_EXIT_CATEGORIES[run.exitCode] || 'non-zero-exit'];
- } else {
- const raw = workspace.readEnclaveOutput(layout.outPath, config.maxOutputBytes);
- if (raw === undefined) {
- // Covers a missing file, an oversized file, invalid UTF-8, and
- // any non-regular replacement (symlink/FIFO/device/socket).
- failureReason = ['unreadable-output'];
- } else {
- const parsed = parseAndValidateQueryOutput(raw, schema);
- if (!parsed.ok) {
- failureReason = ['nonconformant-output'];
- } else {
- canonicalResult = parsed.canonical;
- }
- }
- }
- } catch (error) {
- failureReason = ['launch-failed', error.message];
- }
- }
- }
-
- // Teardown is part of the observable operation and must complete before the
- // timing bucket is chosen.
- if (layout) {
- workspace.preserveInvocationSession(
- layout.sessionLogPath,
- config.auditDir,
- invocationId,
- );
- }
- if (!safeDestroy(invocationId)) {
- failureReason = ['cleanup-failed'];
- canonicalResult = undefined;
- }
-
- const elapsedMs = clock.nowMs() - startMs;
- const { bucketMs, overflowed } = await waitForBucket(startMs, elapsedMs, clock);
-
- if (overflowed) {
- // Fail closed: processing overran every configured bucket. Never emit a
- // successful result at unbucketed timing.
- audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason[0] : undefined);
- emitInvocationTelemetry('timing-bucket-overflow');
- safeRespond(CANONICAL_ERROR_JSON);
- } else if (canonicalResult !== undefined) {
- audit.invocation({
- invocationId,
- // The repository name is deliberately absent; only its trusted
- // sensitivity class and the charge are recorded.
- sensitivity: seed.sensitivity,
- bits: charge,
- bucketMs,
- });
- emitInvocationTelemetry('success');
- safeRespond(canonicalOkJson(canonicalResult));
- } else {
- const category = failureReason ? failureReason[0] : 'unknown';
- audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined);
- emitInvocationTelemetry(category);
- safeRespond(CANONICAL_ERROR_JSON);
- }
- }
-
- function safeDestroy(invocationId) {
- try {
- workspace.destroyInvocationWorkspace(config.workDir, invocationId);
- return true;
- } catch (error) {
- audit.failure(invocationId, 'cleanup-failed', error.message);
- return false;
- }
- }
-
- return {
- /** Stops admitting new invocations while letting admitted work drain. */
- close() {
- accepting = false;
- },
-
- /**
- * Handles one request. `respond` is called exactly once with the canonical
- * result JSON. Requests are queued so at most one enclave runs at a time.
- */
- handle(request, respond) {
- let responded = false;
- const safeRespond = (json) => {
- if (responded) return;
- responded = true;
- respond(json);
- };
-
- if (!accepting) {
- safeRespond(CANONICAL_ERROR_JSON);
- return Promise.resolve();
- }
-
- // The invocation-count cap is operational and independent of the bit
- // ledger: it is consumed per *response*, so every response the agent
- // observes — including a rejection — counts against it.
- if (invocationsUsed >= config.maxInvocations) {
- audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`);
- emitInvocationTelemetry('invocation-count-exhausted');
- safeRespond(CANONICAL_ERROR_JSON);
- return Promise.resolve();
- }
- invocationsUsed += 1;
-
- const queued = tail.then(() => execute(request, safeRespond)).catch((error) => {
- audit.failure('queue', 'unexpected-error', error && error.message);
- emitInvocationTelemetry('unexpected-error');
- safeRespond(CANONICAL_ERROR_JSON);
- });
- tail = queued.then(
- () => undefined,
- () => undefined,
- );
- return queued;
- },
-
- /** Resolves when every admitted invocation has finished broker-side work. */
- drain() {
- return tail;
- },
-
- /** @internal Exposed for tests. */
- get invocationsUsed() {
- return invocationsUsed;
- },
-
- /** @internal Exposed for tests. Never surfaced on the wire. */
- ledger,
- };
-}
-
-module.exports = { createBroker };
diff --git a/containers/bounded-agent/broker/config.js b/containers/bounded-agent/broker/config.js
deleted file mode 100644
index bd0222652..000000000
--- a/containers/bounded-agent/broker/config.js
+++ /dev/null
@@ -1,242 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const { MAX_QUERY_TIMEOUT_SECONDS, MAX_RESULT_BYTES } = require('./protocol');
-const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity');
-const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging');
-
-/**
- * Bounded-agent broker configuration.
- *
- * Everything here is supplied by AWF through the container environment and
- * fixed mount points. Nothing in this file is influenced by a request: the
- * caller cannot choose an image, a runtime, a network, an endpoint, a model, a
- * profile, a path, a mount, a limit, or a timeout.
- */
-
-const SEEDS_DIR = '/srv/awf/seeds';
-const WORK_DIR = '/srv/awf/work';
-const SEED_MAP_PATH = '/srv/awf/seed-map.json';
-const SOCKET_DIR = '/run/awf-bounded-agent';
-const SOCKET_PATH = path.join(SOCKET_DIR, 'broker.sock');
-const CONTROL_DIR = '/run/awf-bounded-agent-control';
-const AUDIT_DIR = '/var/log/awf-bounded-agent';
-/** Broker-private readiness marker; the control directory is never agent-mounted. */
-const READY_PATH = path.join(CONTROL_DIR, 'broker.ready');
-const ENCLAVE_SECCOMP_PATH = '/opt/awf/enclave-seccomp.json';
-
-/** Mount points inside the enclave container. Fixed, never caller-supplied. */
-const ENCLAVE_MOUNT_DIR = '/agent';
-const ENCLAVE_SEED_PATH = '/awf/seed';
-const ENCLAVE_TASK_PATH = '/awf/task.txt';
-const ENCLAVE_SCHEMA_PATH = '/awf/schema.json';
-
-/** Unprivileged uid/gid the enclave process runs as. */
-const ENCLAVE_UID = 65534;
-const ENCLAVE_GID = 65534;
-
-/** Hard ceiling on the caller-supplied task text, mirrored from the TS protocol. */
-const MAX_TASK_BYTES = 64 * 1024;
-
-const SUPPORTED_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const SUPPORTED_ENGINES = new Set(['copilot']);
-const SUPPORTED_PROFILES = new Set(['openai', 'anthropic']);
-const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json');
-
-function requireEnv(name) {
- const value = process.env[name];
- if (!value || value.length === 0) {
- throw new Error(`Missing required environment variable: ${name}`);
- }
- return value;
-}
-
-function parsePositiveInt(name, fallback) {
- const raw = process.env[name];
- if (raw === undefined || raw === '') return fallback;
- const parsed = Number.parseInt(raw, 10);
- if (!Number.isInteger(parsed) || parsed < 1) {
- throw new Error(`Environment variable ${name} must be a positive integer`);
- }
- return parsed;
-}
-
-function parseBoundedInt(name, fallback, maximum) {
- const parsed = parsePositiveInt(name, fallback);
- if (parsed > maximum) {
- throw new Error(`Environment variable ${name} must be at most ${maximum}`);
- }
- return parsed;
-}
-
-/**
- * Parses the per-invocation timeout, additionally re-enforcing (defense in
- * depth; AWF's host-side preflight already rejects an out-of-range value
- * before this container ever starts) that it preserves the final response
- * bucket's post-processing margin.
- */
-function parseTimeoutSeconds() {
- const parsed = parsePositiveInt('AWF_BOUNDED_AGENT_TIMEOUT', 120);
- if (parsed > MAX_QUERY_TIMEOUT_SECONDS) {
- throw new Error(
- `Environment variable AWF_BOUNDED_AGENT_TIMEOUT must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` +
- '(the final response bucket reserves one minute for termination, validation, and cleanup)',
- );
- }
- return parsed;
-}
-
-function parseDockerSize(name, fallback) {
- const value = process.env[name] || fallback;
- if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(value)) {
- throw new Error(`${name} must be a Docker size limit (e.g. "512m")`);
- }
- return value;
-}
-
-/**
- * Loads the two capability tokens the broker's TCP listener requires on
- * every request when reachability is via authenticated primary-sbx ingress
- * (never used for the Unix-socket transport). Generated fresh per run on the
- * trusted host only after runtime proofs succeed; never logged, telemetered,
- * or written to any audit/skill surface.
- */
-function loadSbxIngressCapabilities(capabilityPath) {
- const parsed = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
- const pattern = /^[0-9a-f]{64}$/;
- if (
- !parsed
- || parsed.version !== 1
- || typeof parsed.query !== 'string'
- || typeof parsed.probe !== 'string'
- || !pattern.test(parsed.query)
- || !pattern.test(parsed.probe)
- || parsed.query === parsed.probe
- ) {
- throw new Error('SBX ingress capability file is malformed');
- }
- return { query: parsed.query, probe: parsed.probe };
-}
-
-function loadConfig() {
- const backend = requireEnv('AWF_BOUNDED_AGENT_BACKEND');
- if (!SUPPORTED_BACKENDS.has(backend)) {
- throw new Error(`Unsupported AWF_BOUNDED_AGENT_BACKEND: ${backend}`);
- }
-
- const engine = requireEnv('AWF_BOUNDED_AGENT_ENGINE');
- if (!SUPPORTED_ENGINES.has(engine)) {
- throw new Error(`Unsupported AWF_BOUNDED_AGENT_ENGINE: ${engine}`);
- }
-
- const profile = requireEnv('AWF_BOUNDED_AGENT_PROFILE');
- if (!SUPPORTED_PROFILES.has(profile)) {
- throw new Error(`Unsupported AWF_BOUNDED_AGENT_PROFILE: ${profile}`);
- }
-
- const apiEndpoint = requireEnv('AWF_BOUNDED_AGENT_API_ENDPOINT');
- if (!/^http:\/\/[0-9a-zA-Z.:-]+$/.test(apiEndpoint)) {
- throw new Error('AWF_BOUNDED_AGENT_API_ENDPOINT must be a bare http origin');
- }
-
- const network = requireEnv('AWF_BOUNDED_AGENT_NETWORK');
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(network)) {
- throw new Error('AWF_BOUNDED_AGENT_NETWORK is not a Docker network name');
- }
-
- const primaryBackend = requireEnv('AWF_BOUNDED_AGENT_PRIMARY_BACKEND');
- if (!PRIMARY_BACKENDS.has(primaryBackend)) {
- throw new Error(`Unsupported AWF_BOUNDED_AGENT_PRIMARY_BACKEND: ${primaryBackend}`);
- }
-
- const tcpPortRaw = process.env.AWF_BOUNDED_AGENT_TCP_PORT;
- const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_AGENT_TCP_PORT');
- if (tcpPort !== undefined && tcpPort > 65535) {
- throw new Error('AWF_BOUNDED_AGENT_TCP_PORT must be a valid TCP port');
- }
-
- // sbx and Docker daemons can have different filesystem namespaces
- // (ARC/DinD); never reuse the Docker-daemon-visible paths for sbx mounts.
- const sbxWorkDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_WORK_DIR') : undefined;
- const sbxSeedsDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_SEEDS_DIR') : undefined;
-
- return {
- seedsDir: SEEDS_DIR,
- workDir: WORK_DIR,
- seedMapPath: SEED_MAP_PATH,
- socketDir: SOCKET_DIR,
- socketPath: SOCKET_PATH,
- controlDir: CONTROL_DIR,
- readyPath: READY_PATH,
- auditDir: AUDIT_DIR,
- enclaveSeccompPath: ENCLAVE_SECCOMP_PATH,
- enclaveMountDir: ENCLAVE_MOUNT_DIR,
- enclaveSeedPath: ENCLAVE_SEED_PATH,
- enclaveTaskPath: ENCLAVE_TASK_PATH,
- enclaveSchemaPath: ENCLAVE_SCHEMA_PATH,
- enclaveUid: ENCLAVE_UID,
- enclaveGid: ENCLAVE_GID,
- enclaveImage: requireEnv('AWF_BOUNDED_AGENT_IMAGE'),
- backend,
- engine,
- profile,
- model: requireEnv('AWF_BOUNDED_AGENT_MODEL'),
- apiEndpoint,
- network,
- // The daemon resolves enclave bind-mount sources in *its* filesystem view,
- // which is not necessarily the broker's (ARC/DinD split filesystems).
- hostWorkDir: requireEnv('AWF_BOUNDED_AGENT_HOST_WORK_DIR'),
- hostSeedsDir: requireEnv('AWF_BOUNDED_AGENT_HOST_SEEDS_DIR'),
- sbxWorkDir,
- sbxSeedsDir,
- primaryBackend,
- tcpPort,
- sbxIngressCapabilities: tcpPort === undefined
- ? undefined
- : loadSbxIngressCapabilities(SBX_CAPABILITY_PATH),
- timeoutSeconds: parseTimeoutSeconds(),
- memoryLimit: parseDockerSize('AWF_BOUNDED_AGENT_MEMORY', '512m'),
- tmpfsLimit: parseDockerSize('AWF_BOUNDED_AGENT_TMPFS', '64m'),
- cpuLimit: process.env.AWF_BOUNDED_AGENT_CPUS || '1',
- pidsLimit: parseBoundedInt('AWF_BOUNDED_AGENT_PIDS', 128, 4096),
- maxOutputBytes: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES),
- maxTaskBytes: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_TASK_BYTES', 4096, MAX_TASK_BYTES),
- maxInvocations: parsePositiveInt('AWF_BOUNDED_AGENT_MAX_INVOCATIONS', 8),
- maxModelRequests: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS', 8, 64),
- maxModelTokens: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS', 1024, 32768),
- socketUid: parsePositiveInt('AWF_BOUNDED_AGENT_SOCKET_UID', 0),
- socketGid: parsePositiveInt('AWF_BOUNDED_AGENT_SOCKET_GID', 0),
- };
-}
-
-/**
- * Loads the AWF-generated repo → { opaque seed id, sensitivity } map.
- *
- * The map is the *only* way a repository can be selected: a request supplies a
- * normalized `owner/repo` id, which is looked up here. Callers never supply a
- * path, and an unknown id is simply absent from the map. Sensitivity is
- * carried in the (AWF-trusted, host-written) map itself, never accepted from a
- * request. The bounded-agent broker loads its *own* map from its own private
- * root, so its ledger is disjoint from the bounded-query ledger.
- */
-function loadSeedMap(seedMapPath) {
- return parsePrivateRepositorySeedMap(
- fs.readFileSync(seedMapPath, 'utf8'),
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
- );
-}
-
-module.exports = {
- READY_PATH,
- SBX_CAPABILITY_PATH,
- MAX_TASK_BYTES,
- SUPPORTED_BACKENDS,
- SUPPORTED_ENGINES,
- SUPPORTED_PROFILES,
- PRIMARY_BACKENDS,
- loadConfig,
- loadSeedMap,
- loadSbxIngressCapabilities,
-};
diff --git a/containers/bounded-agent/broker/framing.js b/containers/bounded-agent/broker/framing.js
deleted file mode 100644
index 8162852d9..000000000
--- a/containers/bounded-agent/broker/framing.js
+++ /dev/null
@@ -1,289 +0,0 @@
-'use strict';
-
-const {
- MAX_PRIVATE_REPO_LENGTH,
- MAX_SCHEMA_BYTES,
- BOUNDED_QUERY_REPO_PATTERN,
- strictParseJson,
- validateSchema,
-} = require('./protocol');
-const { MAX_TASK_BYTES } = require('./config');
-
-/**
- * Wire framing and request validation for the agent → broker bounded-agent
- * request.
- *
- * Like bounded queries, the scalar/JSON fields travel as fixed headers and the
- * free-form payload (here the bounded task text) travels as the raw body, so
- * the POSIX-sh agent wrapper never has to emit JSON. The broker assembles the
- * canonical `{privateRepo, schema, task}` object itself and validates it
- * against the fixed protocol.
- *
- * The accepted surface is deliberately tiny. Any other `x-awf-*` header, any
- * duplicate header, and any unknown/forbidden request key is rejected — a
- * request can never express an image, command, executable, mount, environment,
- * endpoint, network, proxy, credential, timeout, resource limit, runtime, or
- * tool definition.
- */
-
-/** Supported request framing version. */
-const AGENT_PROTOCOL_VERSION = '1';
-
-const VERSION_HEADER = 'x-awf-agent-version';
-const REPO_HEADER = 'x-awf-repo';
-const SCHEMA_HEADER = 'x-awf-schema-b64';
-
-/** Every header the broker accepts. Anything else is a rejected control. */
-const ALLOWED_AWF_HEADERS = new Set([VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER]);
-
-/** The complete set of keys a bounded-agent request may contain. */
-const ALLOWED_REQUEST_KEYS = ['privateRepo', 'schema', 'task'];
-
-/**
- * Every accepted spelling of the single free-form payload field.
- *
- * Exactly one of these is accepted per caller surface (`task` for the legacy
- * bounded-agent wrapper protocol, `prompt` for the unified enclave MCP tool);
- * the other is an explicitly forbidden control so a request can never smuggle
- * a second payload past the finite-disclosure charge.
- */
-const PAYLOAD_KEYS = ['task', 'prompt'];
-
-/**
- * Controls a request may never express.
- *
- * Redundant with the unknown-key rule below by construction; kept explicit so
- * an accidental future widening of the accepted key set fails a test instead of
- * silently granting a capability.
- */
-const BASE_FORBIDDEN_REQUEST_KEYS = [
- 'image', 'images', 'command', 'cmd', 'args', 'argv', 'entrypoint', 'executable',
- 'interpreter', 'script', 'shell', 'mount', 'mounts', 'volume', 'volumes', 'bind',
- 'path', 'paths', 'workdir', 'env', 'environment', 'endpoint', 'endpoints', 'baseUrl',
- 'url', 'host', 'network', 'networks', 'dns', 'proxy', 'httpProxy', 'httpsProxy',
- 'credential', 'credentials', 'apiKey', 'token', 'authorization', 'headers',
- 'timeout', 'timeoutSeconds', 'deadline', 'memory', 'memoryLimit', 'cpu', 'cpuLimit',
- 'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'engine', 'sandbox',
- 'profile', 'model', 'provider', 'temperature', 'maxTokens', 'maxModelRequests',
- 'tool', 'tools', 'toolChoice', 'functions', 'systemPrompt', 'system', 'messages',
-];
-
-/** Forbidden controls for one caller surface: everything plus the other payload spelling. */
-function forbiddenKeysFor(payloadKey) {
- return BASE_FORBIDDEN_REQUEST_KEYS.concat(PAYLOAD_KEYS.filter((key) => key !== payloadKey));
-}
-
-/** Forbidden controls for the legacy `task` wrapper surface. */
-const FORBIDDEN_REQUEST_KEYS = forbiddenKeysFor('task');
-
-/** Base64url alphabet only (no padding, no `+`/`/`). */
-const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
-
-/** Generous ceiling on the encoded header length for a schema of at most `MAX_SCHEMA_BYTES`. */
-const MAX_SCHEMA_HEADER_LENGTH = Math.ceil((MAX_SCHEMA_BYTES * 4) / 3) + 4;
-
-/** A peer that stops sending a request body cannot pin a broker connection. */
-const BODY_READ_TIMEOUT_MS = 5_000;
-
-/**
- * Rejects duplicated or unexpected `x-awf-*` headers.
- *
- * Duplicates matter because Node joins repeated headers with `", "`, which
- * would silently corrupt a base64url value or a repo slug.
- */
-function validateRawHeaders(rawHeaders) {
- const seen = new Set();
- for (let i = 0; i < rawHeaders.length; i += 2) {
- const name = rawHeaders[i].toLowerCase();
- if (!name.startsWith('x-awf-')) continue;
- if (!ALLOWED_AWF_HEADERS.has(name)) {
- return `unsupported request control header: ${name}`;
- }
- if (seen.has(name)) {
- return `duplicate request header: ${name}`;
- }
- seen.add(name);
- }
- return undefined;
-}
-
-/** Decodes and UTF-8-validates the base64url schema header. */
-function decodeSchemaHeader(value) {
- if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SCHEMA_HEADER_LENGTH) {
- return undefined;
- }
- if (!BASE64URL_PATTERN.test(value)) return undefined;
-
- let decoded;
- try {
- decoded = Buffer.from(value, 'base64url');
- } catch {
- return undefined;
- }
- const text = decoded.toString('utf8');
- // Reject anything that was not valid UTF-8 to begin with (round-trip check).
- if (!Buffer.from(text, 'utf8').equals(decoded)) return undefined;
- return text;
-}
-
-/**
- * Assembles the canonical request object from a framed HTTP request.
- *
- * @returns `{ request }` on success or `{ error }` with a protected reason.
- */
-function buildRequestFromFrame(headers, rawHeaders, task) {
- const headerError = validateRawHeaders(rawHeaders);
- if (headerError) return { error: headerError };
-
- if (headers[VERSION_HEADER] !== AGENT_PROTOCOL_VERSION) {
- return { error: 'unsupported or missing protocol version' };
- }
-
- const privateRepo = headers[REPO_HEADER];
- if (typeof privateRepo !== 'string') {
- return { error: 'missing repository selector' };
- }
-
- const schemaText = decodeSchemaHeader(headers[SCHEMA_HEADER]);
- if (schemaText === undefined) {
- return { error: 'missing or malformed schema header' };
- }
-
- const parsedSchema = strictParseJson(schemaText);
- if (!parsedSchema) {
- return { error: 'schema header is not valid JSON' };
- }
-
- return { request: { privateRepo, schema: parsedSchema.value, task } };
-}
-
-function isPlainObject(value) {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-/**
- * Validates an assembled bounded-agent request against the fixed protocol.
- *
- * @returns `{ valid: true, request }` or `{ valid: false, errors }`. Errors are
- * only ever written to the protected audit log, never returned to the caller.
- */
-function validateBoundedAgentRequest(raw, options = {}) {
- const errors = [];
- if (!isPlainObject(raw)) {
- return { valid: false, errors: ['request must be a JSON object'] };
- }
-
- // Trusted caller-surface selection, never request data. Exactly one payload
- // spelling is accepted; the others stay forbidden controls.
- const payloadKey = PAYLOAD_KEYS.includes(options.payloadKey) ? options.payloadKey : 'task';
- const allowedKeys = ['privateRepo', 'schema', payloadKey];
- const forbidden = forbiddenKeysFor(payloadKey).filter(
- (key) => Object.prototype.hasOwnProperty.call(raw, key),
- );
- for (const key of forbidden) {
- errors.push(`request may not specify "${key}"`);
- }
- for (const key of Object.keys(raw)) {
- if (!allowedKeys.includes(key) && !forbidden.includes(key)) {
- errors.push(`unknown request key: "${key}"`);
- }
- }
-
- const { privateRepo, schema } = raw;
- const task = raw[payloadKey];
-
- if (typeof privateRepo !== 'string') {
- errors.push('privateRepo must be a string');
- } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH) {
- errors.push('privateRepo exceeds the maximum length');
- } else if (!BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) {
- errors.push('privateRepo must be a bare owner/repo slug');
- }
-
- const schemaValidation = validateSchema(schema);
- if (!schemaValidation.valid) {
- errors.push(...schemaValidation.errors);
- }
-
- const configuredLimit = Number.isInteger(options.maxTaskBytes) && options.maxTaskBytes > 0
- ? options.maxTaskBytes
- : MAX_TASK_BYTES;
- const taskLimit = Math.min(configuredLimit, MAX_TASK_BYTES);
- if (typeof task !== 'string') {
- errors.push(`${payloadKey} must be a string`);
- } else if (task.length === 0) {
- errors.push(`${payloadKey} must not be empty`);
- } else if (Buffer.byteLength(task, 'utf8') > taskLimit) {
- errors.push(`${payloadKey} exceeds the maximum size`);
- }
-
- if (errors.length > 0) return { valid: false, errors };
-
- return {
- valid: true,
- request: { privateRepo, schema: schemaValidation.schema, [payloadKey]: task },
- };
-}
-
-/**
- * Reads the request body, refusing anything above the hard task cap.
- *
- * The cap is enforced while streaming so an oversized body is never buffered.
- * The *configured* (possibly smaller) cap is applied by
- * {@link validateBoundedAgentRequest}.
- */
-function readBoundedBody(req) {
- return new Promise((resolve) => {
- const chunks = [];
- let total = 0;
- let settled = false;
- const timer = setTimeout(() => {
- req.pause();
- finish({ error: 'request body deadline exceeded' });
- }, BODY_READ_TIMEOUT_MS);
- timer.unref();
-
- const finish = (value) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- resolve(value);
- };
-
- req.on('data', (chunk) => {
- total += chunk.length;
- if (total > MAX_TASK_BYTES) {
- finish({ error: 'task exceeds maximum size' });
- req.pause();
- return;
- }
- chunks.push(chunk);
- });
- req.on('end', () => {
- const body = Buffer.concat(chunks);
- const text = body.toString('utf8');
- if (!Buffer.from(text, 'utf8').equals(body)) {
- finish({ error: 'task is not valid UTF-8' });
- return;
- }
- finish({ task: text });
- });
- req.on('error', () => finish({ error: 'request stream error' }));
- });
-}
-
-module.exports = {
- AGENT_PROTOCOL_VERSION,
- ALLOWED_REQUEST_KEYS,
- MAX_TASK_BYTES,
- PAYLOAD_KEYS,
- BODY_READ_TIMEOUT_MS,
- FORBIDDEN_REQUEST_KEYS,
- forbiddenKeysFor,
- REPO_HEADER,
- SCHEMA_HEADER,
- VERSION_HEADER,
- buildRequestFromFrame,
- readBoundedBody,
- validateBoundedAgentRequest,
-};
diff --git a/containers/bounded-agent/broker/healthcheck.js b/containers/bounded-agent/broker/healthcheck.js
deleted file mode 100644
index 0e90da52c..000000000
--- a/containers/bounded-agent/broker/healthcheck.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const { READY_PATH } = require('./config');
-
-/**
- * Compose healthcheck for the bounded-agent broker.
- *
- * Checks for the broker-internal ready file written by `main()` in server.js
- * once the socket is accepting connections. This avoids hitting the
- * agent-visible `/query` socket, which has only one route and no health
- * endpoint. Exits non-zero if the ready file is absent or unreadable.
- */
-
-try {
- fs.accessSync(READY_PATH, fs.constants.F_OK);
- process.exit(0);
-} catch {
- process.exit(1);
-}
diff --git a/containers/bounded-agent/broker/ledger.js b/containers/bounded-agent/broker/ledger.js
deleted file mode 100644
index 483ea80c8..000000000
--- a/containers/bounded-agent/broker/ledger.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-agent compatibility entrypoint.
-module.exports = require('../bounded-execution/sensitivity-ledger');
diff --git a/containers/bounded-agent/broker/protocol.js b/containers/bounded-agent/broker/protocol.js
deleted file mode 100644
index e9a069c61..000000000
--- a/containers/bounded-agent/broker/protocol.js
+++ /dev/null
@@ -1,6 +0,0 @@
-'use strict';
-
-// Stable bounded-agent compatibility entrypoint for the shared PR1
-// bounded-execution foundation (finite schema algebra, cardinality/bit charge,
-// strict JSON parsing, canonicalization, canonical envelopes).
-module.exports = require('../bounded-execution/finite-disclosure');
diff --git a/containers/bounded-agent/broker/runtime-telemetry.js b/containers/bounded-agent/broker/runtime-telemetry.js
deleted file mode 100644
index a7e859285..000000000
--- a/containers/bounded-agent/broker/runtime-telemetry.js
+++ /dev/null
@@ -1,64 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-
-const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const BOUNDED_AGENT_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'invocation', 'cleanup']);
-const CAPABILITY_STATES = new Set(['supported', 'unavailable', 'blocked']);
-const CATEGORY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
-
-function assertTelemetryValue(allowed, value, field) {
- if (!allowed.has(value)) throw new Error(`Invalid bounded-agent telemetry ${field}`);
-}
-
-function buildRuntimeTelemetryRecord(event) {
- assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend');
- assertTelemetryValue(BOUNDED_AGENT_BACKENDS, event.boundedAgentBackend, 'boundedAgentBackend');
- assertTelemetryValue(LIFECYCLE_CLASSES, event.lifecycleClass, 'lifecycleClass');
- assertTelemetryValue(CAPABILITY_STATES, event.capabilityState, 'capabilityState');
- if (typeof event.category !== 'string' || !CATEGORY_PATTERN.test(event.category)) {
- throw new Error('Invalid bounded-agent telemetry category');
- }
- return Object.freeze({
- primaryBackend: event.primaryBackend,
- boundedAgentBackend: event.boundedAgentBackend,
- lifecycleClass: event.lifecycleClass,
- capabilityState: event.capabilityState,
- category: event.category,
- });
-}
-
-/**
- * Runtime-matrix telemetry sink, mirroring bounded-query's.
- *
- * Only the five fixed enum fields above are ever written — never a secret,
- * capability token, path, prompt, repository id, model payload, or provider
- * response. This is intentionally the *only* channel this broker writes
- * besides the disjoint audit ledger in `./audit.js`.
- */
-function createRuntimeTelemetry(auditDir) {
- fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
- const telemetryPath = path.join(auditDir, 'runtime-telemetry.jsonl');
- let fd = fs.openSync(telemetryPath, 'a', 0o600);
- return {
- emit(event) {
- const record = buildRuntimeTelemetryRecord(event);
- if (fd === undefined) return;
- try {
- fs.writeSync(fd, `${JSON.stringify(record)}\n`);
- } catch {
- process.stderr.write('[bounded-agent] runtime telemetry unavailable\n');
- try {
- fs.closeSync(fd);
- } catch {
- // The generic telemetry failure above is the only safe diagnostic.
- }
- fd = undefined;
- }
- },
- };
-}
-
-module.exports = { buildRuntimeTelemetryRecord, createRuntimeTelemetry };
diff --git a/containers/bounded-agent/broker/scheduler.js b/containers/bounded-agent/broker/scheduler.js
deleted file mode 100644
index 4106ec243..000000000
--- a/containers/bounded-agent/broker/scheduler.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-agent compatibility entrypoint.
-module.exports = require('../bounded-execution/fixed-timing');
diff --git a/containers/bounded-agent/broker/sensitivity.js b/containers/bounded-agent/broker/sensitivity.js
deleted file mode 100644
index 394ad7fe4..000000000
--- a/containers/bounded-agent/broker/sensitivity.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-agent compatibility entrypoint.
-module.exports = require('../bounded-execution/sensitivity-policy');
diff --git a/containers/bounded-agent/broker/server.js b/containers/bounded-agent/broker/server.js
deleted file mode 100644
index b37f64421..000000000
--- a/containers/bounded-agent/broker/server.js
+++ /dev/null
@@ -1,410 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const crypto = require('crypto');
-const http = require('http');
-const { createAuditLog } = require('./audit');
-const { createBroker } = require('./broker');
-const { loadConfig, loadSeedMap } = require('./config');
-const { buildRequestFromFrame, readBoundedBody } = require('./framing');
-const { CANONICAL_ERROR_JSON } = require('./protocol');
-const { createEnclaveRunner } = require('./enclave-runner');
-const { createRuntimeTelemetry } = require('./runtime-telemetry');
-
-/**
- * Bounded-agent broker server.
- *
- * Compose agents (docker/gvisor primary) reach the broker over a Unix domain
- * socket shared through a tightly scoped bind mount; the broker itself has
- * `network_mode: none` in that mode -- not on `awf-net`, not on `awf-ext`, and
- * not on the dedicated bounded-agent enclave network. sbx primary agents use
- * the same protocol over authenticated HTTP only when a disposable capability
- * probe proves that sbx cannot connect through a mounted host socket. In that
- * mode the broker is attached only to a dedicated internal Docker network and
- * published on an ephemeral host-gateway-only port; it is never on the
- * enclave egress network either way.
- *
- * One route exists:
- * POST /query the bounded-agent API
- *
- * The agent-visible socket has no `/health` route. The compose healthcheck
- * instead polls for a broker-internal ready file written by `main()` after the
- * socket starts accepting connections, so there is no distinguishable extra
- * response on the agent-observable surface.
- *
- * `/query` always answers `200` with a canonical result body:
- * `{"status":"ok","result":}` or `{"status":"error"}` -- status code and
- * headers are identical either way, and every failure class collapses to the
- * same error body. For any invocation that reached workspace creation, the
- * response is additionally held until a fixed timing-bucket boundary.
- */
-
-const RESULT_HEADERS = {
- 'content-type': 'application/json',
- 'cache-control': 'no-store',
-};
-// Give a nearly-complete invocation a chance to finish broker cleanup before
-// force-removing this run's enclaves. Longer invocations are interrupted so
-// Compose shutdown remains bounded; host teardown owns private-root removal.
-const SHUTDOWN_GRACE_MS = 1_000;
-const MAX_HEADER_BYTES = 8 * 1024;
-const MAX_CONNECTIONS = 32;
-const PROBE_RESPONSE_DELAY_MS = 10;
-
-function sendResult(res, body) {
- res.writeHead(200, { ...RESULT_HEADERS, 'content-length': Buffer.byteLength(body) });
- res.end(body);
-}
-
-function canonicalRawResponse() {
- return [
- 'HTTP/1.1 200 OK',
- 'content-type: application/json',
- 'cache-control: no-store',
- `content-length: ${Buffer.byteLength(CANONICAL_ERROR_JSON)}`,
- 'connection: close',
- '',
- CANONICAL_ERROR_JSON,
- ].join('\r\n');
-}
-
-function createHardenedServer(listener, audit) {
- let accepting = true;
- let pendingAdmissions = 0;
- const admissionWaiters = [];
- const resolveAdmissionWaiters = () => {
- if (pendingAdmissions !== 0) return;
- while (admissionWaiters.length > 0) {
- admissionWaiters.shift()();
- }
- };
-
- const server = http.createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (req, res) => {
- if (!accepting) {
- sendResult(res, CANONICAL_ERROR_JSON);
- req.resume();
- return;
- }
-
- pendingAdmissions += 1;
- Promise.resolve(listener(req, res, () => accepting))
- .catch((error) => {
- audit.failure('server', 'unhandled-error', error && error.message);
- if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON);
- })
- .finally(() => {
- pendingAdmissions -= 1;
- resolveAdmissionWaiters();
- });
- });
- server.headersTimeout = 5_000;
- server.requestTimeout = 0;
- server.keepAliveTimeout = 1_000;
- server.maxRequestsPerSocket = 1;
-
- let activeConnections = 0;
- server.on('connection', (socket) => {
- activeConnections += 1;
- socket.once('close', () => {
- activeConnections -= 1;
- });
- if (activeConnections > MAX_CONNECTIONS) {
- socket.awfRejected = true;
- audit.failure('transport', 'connection-limit');
- socket.pause();
- socket.end(canonicalRawResponse());
- }
- });
- server.on('clientError', (error, socket) => {
- audit.failure('framing', 'header-rejected', error && error.message);
- setTimeout(() => {
- if (socket.writable) socket.end(canonicalRawResponse());
- }, PROBE_RESPONSE_DELAY_MS);
- });
- server.freezeAdmissions = () => {
- accepting = false;
- };
- server.drainAdmissions = () => (
- pendingAdmissions === 0
- ? Promise.resolve()
- : new Promise((resolve) => admissionWaiters.push(resolve))
- );
- return server;
-}
-
-function processRequest(
- req,
- res,
- broker,
- audit,
- framedHeaders = req.headers,
- framedRawHeaders = req.rawHeaders,
- isAccepting = () => true,
-) {
- if (req.socket.awfRejected) {
- req.resume();
- res.destroy();
- return Promise.resolve();
- }
- if (req.method !== 'POST' || req.url !== '/query') {
- sendResult(res, CANONICAL_ERROR_JSON);
- req.resume();
- return Promise.resolve();
- }
-
- return readBoundedBody(req)
- .then((body) => {
- if (!isAccepting()) {
- sendResult(res, CANONICAL_ERROR_JSON);
- return;
- }
-
- if (body.error !== undefined) {
- audit.failure('framing', 'body-rejected', body.error);
- return broker.handle(undefined, (result) => sendResult(res, result));
- }
-
- const framed = buildRequestFromFrame(framedHeaders, framedRawHeaders, body.task);
- if (framed.error !== undefined) {
- audit.failure('framing', 'frame-rejected', framed.error);
- return broker.handle(undefined, (result) => sendResult(res, result));
- }
-
- return broker.handle(framed.request, (result) => sendResult(res, result));
- })
- .catch((error) => {
- audit.failure('server', 'unhandled-error', error && error.message);
- if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON);
- });
-}
-
-function createServer(deps) {
- const { broker, audit } = deps;
- return createHardenedServer(
- (req, res, isAccepting) => processRequest(
- req,
- res,
- broker,
- audit,
- req.headers,
- req.rawHeaders,
- isAccepting,
- ),
- audit,
- );
-}
-
-function safeCapabilityEquals(actual, expected) {
- if (typeof actual !== 'string') return false;
- const actualBytes = Buffer.from(actual, 'utf8');
- const expectedBytes = Buffer.from(expected, 'utf8');
- return actualBytes.length === expectedBytes.length
- && crypto.timingSafeEqual(actualBytes, expectedBytes);
-}
-
-function stripCapabilityHeader(req) {
- const headers = { ...req.headers };
- delete headers['x-awf-capability'];
- const rawHeaders = [];
- for (let i = 0; i < req.rawHeaders.length; i += 2) {
- if (req.rawHeaders[i].toLowerCase() === 'x-awf-capability') continue;
- rawHeaders.push(req.rawHeaders[i], req.rawHeaders[i + 1]);
- }
- return { headers, rawHeaders };
-}
-
-/**
- * Authenticated HTTP listener for sbx-primary reachability only. Every
- * request must carry exactly one `x-awf-capability` header matching the
- * broker-generated `query` token; the distinct `probe` token is accepted
- * exactly once (pre-agent reachability proof), then permanently retired for
- * the lifetime of this process. Neither token is ever logged, telemetered, or
- * written to the audit ledger -- only the fixed category strings
- * `'auth-rejected'` / `'sbx-ingress-probe'` are.
- */
-function createTcpServer(deps) {
- const { broker, audit, capabilities } = deps;
- let probeAvailable = true;
- return createHardenedServer((req, res, isAccepting) => {
- const capabilityHeaders = req.rawHeaders.filter(
- (_value, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'x-awf-capability',
- );
- const supplied = req.headers['x-awf-capability'];
- const isQuery = capabilityHeaders.length === 1 && safeCapabilityEquals(supplied, capabilities.query);
- const isProbe = (
- probeAvailable
- && capabilityHeaders.length === 1
- && safeCapabilityEquals(supplied, capabilities.probe)
- );
-
- if (isProbe) {
- probeAvailable = false;
- audit.lifecycle('sbx-ingress-probe');
- req.resume();
- return new Promise((resolve) => {
- setTimeout(() => {
- sendResult(res, CANONICAL_ERROR_JSON);
- resolve();
- }, PROBE_RESPONSE_DELAY_MS);
- });
- }
-
- if (!isQuery) {
- audit.failure('transport', 'auth-rejected');
- req.resume();
- sendResult(res, CANONICAL_ERROR_JSON);
- return Promise.resolve();
- }
-
- const framed = stripCapabilityHeader(req);
- return processRequest(
- req,
- res,
- broker,
- audit,
- framed.headers,
- framed.rawHeaders,
- isAccepting,
- );
- }, audit);
-}
-
-function listenOnSocket(server, config, audit) {
- fs.rmSync(config.socketPath, { force: true });
- fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o770 });
-
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(config.socketPath, () => {
- try {
- // The agent runs as the host user; hand it the socket explicitly
- // rather than making the socket world-writable.
- fs.chownSync(config.socketPath, config.socketUid, config.socketGid);
- fs.chmodSync(config.socketPath, 0o660);
- } catch (error) {
- audit.lifecycle('socket-ownership-fallback', error.message);
- fs.chmodSync(config.socketPath, 0o666);
- }
-
- resolve();
- });
- });
-}
-
-function listenOnTcp(server, config) {
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(config.tcpPort, '0.0.0.0', resolve);
- });
-}
-
-async function main() {
- const config = loadConfig();
- const audit = createAuditLog(config.auditDir);
- const telemetry = createRuntimeTelemetry(config.auditDir);
- const { runId, seeds } = loadSeedMap(config.seedMapPath);
- const runner = createEnclaveRunner(config);
-
- // Fail closed before accepting requests and deterministically reconcile
- // enclaves left by a prior broker process for this exact run. Enclaves
- // never pull and never fall back.
- await runner.assertAvailable();
- await runner.reconcileRun(runId);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- boundedAgentBackend: config.backend,
- lifecycleClass: 'startup',
- capabilityState: 'supported',
- category: 'ready',
- });
-
- const broker = createBroker({ config, seedMap: seeds, runId, audit, runner, telemetry });
- const unixServer = createServer({ broker, audit });
- const servers = [unixServer];
-
- await listenOnSocket(unixServer, config, audit);
- if (config.tcpPort !== undefined) {
- const tcpServer = createTcpServer({
- broker,
- audit,
- capabilities: config.sbxIngressCapabilities,
- });
- await listenOnTcp(tcpServer, config);
- servers.push(tcpServer);
- }
-
- // Write the ready file AFTER the socket is accepting connections. The compose
- // healthcheck polls this file in the broker-only control mount.
- fs.mkdirSync(config.controlDir, { recursive: true, mode: 0o700 });
- fs.writeFileSync(config.readyPath, '', { mode: 0o644 });
-
- // Deliberately records no repository names, task text, model identity, or
- // host paths beyond the fixed socket location.
- audit.lifecycle('listening', {
- repos: seeds.size,
- backend: config.backend,
- profile: config.profile,
- ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http',
- maxInvocations: config.maxInvocations,
- });
-
- let shuttingDown = false;
- const shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
- broker.close();
- for (const server of servers) {
- server.freezeAdmissions();
- server.close();
- }
- const forcedExit = setTimeout(() => process.exit(1), 5000);
- forcedExit.unref();
- try {
- await Promise.race([
- Promise.all([
- ...servers.map((server) => server.drainAdmissions()),
- broker.drain(),
- ]),
- new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS)),
- ]);
- // Interrupted invocations leave no enclave behind: reconcile again.
- await runner.reconcileRun(runId);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- boundedAgentBackend: config.backend,
- lifecycleClass: 'cleanup',
- capabilityState: 'supported',
- category: 'success',
- });
- process.exit(0);
- } catch (error) {
- audit.lifecycle('shutdown-cleanup-failed', error.message);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- boundedAgentBackend: config.backend,
- lifecycleClass: 'cleanup',
- capabilityState: 'supported',
- category: 'cleanup-failed',
- });
- process.exit(1);
- }
- };
- process.on('SIGTERM', shutdown);
- process.on('SIGINT', shutdown);
-}
-
-if (require.main === module) {
- main().catch((error) => {
- process.stderr.write(`[bounded-agent] broker failed to start: ${error.message}\n`);
- process.exit(1);
- });
-}
-
-module.exports = {
- createServer,
- createTcpServer,
- listenOnSocket,
- listenOnTcp,
- MAX_HEADER_BYTES,
- MAX_CONNECTIONS,
-};
diff --git a/containers/bounded-agent/enclave-entrypoint.py b/containers/bounded-agent/enclave-entrypoint.py
deleted file mode 100644
index 298bf36bc..000000000
--- a/containers/bounded-agent/enclave-entrypoint.py
+++ /dev/null
@@ -1,592 +0,0 @@
-#!/usr/bin/env python3
-"""Fixed AWF bounded-agent enclave bootstrap.
-
-This is the *only* program that ever runs inside a bounded-agent enclave. It is
-authored by AWF, baked into the image, and mounted nowhere: a request cannot
-replace it, extend it, or pass it arguments.
-
-What it does, in order:
-
- 1. reads the caller's byte-bounded task text and finite response schema from
- fixed read-only paths;
- 2. runs a small, fixed model loop against the AWF API proxy — the enclave's
- only reachable peer — using the trusted profile/model chosen by AWF
- configuration;
- 3. exposes exactly three local, read-only repository tools plus one terminal
- "finish" tool. There is no shell, no network tool, no write tool, no
- package installation, and no way to add a tool;
- 4. writes the final answer, and nothing else, as a single JSON value to the
- dedicated bounded result file.
-
-It deliberately holds no credentials: the API proxy injects the real key. It
-never prints repository contents, task text, model output, or provider payloads
-to stdout/stderr — the broker discards those streams anyway, so anything written
-there would only be a latent leak if that ever changed.
-
-Standard library only.
-"""
-
-import json
-import os
-import re
-import sys
-import time
-import urllib.error
-import urllib.request
-from pathlib import Path
-
-# Fixed mount points. `main()` uses only these; they are never derived from the
-# environment, from the task, or from anything a request can influence.
-SEED_DIR = Path("/awf/seed")
-TASK_PATH = Path("/awf/task.txt")
-SCHEMA_PATH = Path("/awf/schema.json")
-OUT_PATH = Path("/agent/out")
-SESSION_LOG_PATH = Path("/agent/session.jsonl")
-
-
-class Layout:
- """The four fixed paths, threaded explicitly so `run()` stays testable."""
-
- def __init__(self, seed_dir, task_path, schema_path, out_path, session_log_path):
- # Resolved once so containment checks and relative-path reporting agree
- # even when an ancestor is a symlink.
- self.seed_dir = Path(seed_dir).resolve()
- self.task_path = Path(task_path)
- self.schema_path = Path(schema_path)
- self.out_path = Path(out_path)
- self.session_log_path = Path(session_log_path)
-
-# Fixed local tool bounds. Not configurable, and never caller-supplied.
-MAX_LIST_ENTRIES = 200
-MAX_READ_BYTES = 8192
-MAX_SEARCH_RESULTS = 40
-MAX_SEARCH_PATTERN = 200
-MAX_TOOL_RESULT_BYTES = 12000
-HTTP_TIMEOUT_SECONDS = 60
-MAX_SESSION_LOG_BYTES = 1024 * 1024
-EXIT_CONFIGURATION_INVALID = 10
-EXIT_INPUT_INVALID = 11
-EXIT_DEADLINE_EXCEEDED = 20
-EXIT_PROVIDER_HTTP_ERROR = 21
-EXIT_PROVIDER_TRANSPORT_ERROR = 22
-EXIT_PROVIDER_RESPONSE_INVALID = 23
-EXIT_RESULT_WRITE_FAILED = 30
-EXIT_MODEL_LOOP_EXHAUSTED = 31
-
-
-def _fail(code: int) -> "int":
- """Exits with one fixed diagnostic code and without writing a result."""
- return code
-
-
-def _session_event(layout: Layout, event: dict) -> None:
- """Appends one bounded transcript event without headers or credentials."""
- try:
- encoded = (json.dumps(event, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8")
- if len(encoded) > MAX_SESSION_LOG_BYTES:
- return
- current_size = layout.session_log_path.stat().st_size
- if current_size + len(encoded) > MAX_SESSION_LOG_BYTES:
- return
- with open(layout.session_log_path, "ab") as handle:
- handle.write(encoded)
- except (OSError, TypeError, ValueError):
- pass
-
-
-def _env_int(name: str, default: int) -> int:
- raw = os.environ.get(name, "")
- try:
- value = int(raw)
- except (TypeError, ValueError):
- return default
- return value if value > 0 else default
-
-
-def _safe_repo_path(layout: Layout, relative: str) -> "Path | None":
- """Resolves a model-supplied path strictly inside the read-only seed."""
- if not isinstance(relative, str) or len(relative) > 4096:
- return None
- candidate = (layout.seed_dir / relative.lstrip("/")).resolve()
- try:
- candidate.relative_to(layout.seed_dir)
- except ValueError:
- return None
- return candidate
-
-
-def tool_list_files(layout: Layout, args: dict) -> str:
- target = _safe_repo_path(layout, args.get("path", "."))
- if target is None or not target.is_dir():
- return "error: not a directory inside the repository"
- entries = []
- for entry in sorted(target.iterdir())[:MAX_LIST_ENTRIES]:
- kind = "dir" if entry.is_dir() else "file"
- entries.append(f"{kind} {entry.relative_to(layout.seed_dir)}")
- return "\n".join(entries) if entries else "(empty)"
-
-
-def tool_read_file(layout: Layout, args: dict) -> str:
- target = _safe_repo_path(layout, args.get("path", ""))
- if target is None or not target.is_file():
- return "error: not a file inside the repository"
- try:
- data = target.read_bytes()[:MAX_READ_BYTES]
- except OSError:
- return "error: unreadable"
- return data.decode("utf-8", errors="replace")
-
-
-def tool_search(layout: Layout, args: dict) -> str:
- pattern = args.get("pattern", "")
- if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_SEARCH_PATTERN:
- return "error: invalid pattern"
- root = _safe_repo_path(layout, args.get("path", "."))
- if root is None or not root.is_dir():
- return "error: not a directory inside the repository"
- needle = re.escape(pattern)
- matcher = re.compile(needle)
- results = []
- for path in sorted(root.rglob("*")):
- if len(results) >= MAX_SEARCH_RESULTS:
- break
- try:
- relative_path = path.relative_to(layout.seed_dir)
- except ValueError:
- continue
- target = _safe_repo_path(layout, str(relative_path))
- if target is None or not target.is_file():
- continue
- try:
- text = target.read_bytes()[:MAX_READ_BYTES].decode("utf-8", errors="replace")
- except OSError:
- continue
- for lineno, line in enumerate(text.splitlines(), start=1):
- if matcher.search(line):
- results.append(f"{relative_path}:{lineno}")
- break
- return "\n".join(results) if results else "(no matches)"
-
-
-LOCAL_TOOLS = {
- "list_files": tool_list_files,
- "read_file": tool_read_file,
- "search": tool_search,
-}
-
-TOOL_DESCRIPTIONS = [
- {
- "name": "list_files",
- "description": "List entries of a directory inside the read-only repository.",
- "parameters": {
- "type": "object",
- "properties": {"path": {"type": "string"}},
- "required": ["path"],
- },
- },
- {
- "name": "read_file",
- "description": (
- "Read up to %d bytes of a file inside the read-only repository." % MAX_READ_BYTES
- ),
- "parameters": {
- "type": "object",
- "properties": {"path": {"type": "string"}},
- "required": ["path"],
- },
- },
- {
- "name": "search",
- "description": "Find files containing a literal substring inside the read-only repository.",
- "parameters": {
- "type": "object",
- "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
- "required": ["pattern"],
- },
- },
- {
- "name": "finish",
- "description": (
- "Record the final answer. `result` must conform exactly to the declared "
- "response schema. Calling this ends the task."
- ),
- "parameters": {
- "type": "object",
- "properties": {"result": {}},
- "required": ["result"],
- },
- },
-]
-
-
-def _provider_result_schema(schema: dict) -> dict:
- """Converts the finite-disclosure schema into provider tool JSON Schema."""
- schema_type = schema["type"]
- if schema_type == "const":
- return {"const": schema["value"]}
- if schema_type == "boolean":
- return {"type": "boolean"}
- if schema_type == "enum":
- return {"enum": schema["values"]}
- if schema_type == "integer":
- return {
- "type": "integer",
- "minimum": schema["minimum"],
- "maximum": schema["maximum"],
- }
- if schema_type == "object":
- fields = schema["fields"]
- return {
- "type": "object",
- "properties": {
- name: _provider_result_schema(child) for name, child in fields.items()
- },
- "required": list(fields),
- "additionalProperties": False,
- }
- if schema_type == "tuple":
- items = [_provider_result_schema(item) for item in schema["items"]]
- return {
- "type": "array",
- "prefixItems": items,
- "minItems": len(items),
- "maxItems": len(items),
- }
- if schema_type == "array":
- length = schema["length"]
- return {
- "type": "array",
- "items": _provider_result_schema(schema["items"]),
- "minItems": length,
- "maxItems": length,
- }
- if schema_type == "union":
- return {
- "oneOf": [
- {
- "type": "object",
- "properties": {
- "tag": {"const": tag},
- "value": _provider_result_schema(child),
- },
- "required": ["tag", "value"],
- "additionalProperties": False,
- }
- for tag, child in schema["variants"].items()
- ]
- }
- raise ValueError("unsupported finite-disclosure schema")
-
-
-def tool_descriptions(schema_text: str) -> list:
- """Binds the caller's validated finite schema to the terminal finish tool."""
- result_schema = _provider_result_schema(json.loads(schema_text))
- return TOOL_DESCRIPTIONS[:-1] + [
- {
- "name": "finish",
- "description": TOOL_DESCRIPTIONS[-1]["description"],
- "parameters": {
- "type": "object",
- "properties": {"result": result_schema},
- "required": ["result"],
- "additionalProperties": False,
- },
- }
- ]
-
-
-def system_prompt(schema_text: str) -> str:
- return (
- "You are a bounded analysis agent running inside an isolated enclave.\n"
- "Mount points: one private repository is mounted read-only at /awf/seed; "
- "/agent is private invocation state managed by AWF; /tmp is ephemeral. "
- "Only the provided read-only repository tools can access repository content. "
- "Their `path` arguments are relative to /awf/seed: use `.` for the repository "
- "root, `go.mod` for a root file, and `src/file.py` for a nested file. Never "
- "include `/awf/seed` in a tool path. You have no network access other than "
- "this API, no shell, no write access, and no host access.\n\n"
- "Answer the user's task by calling tools, then call `finish` exactly once "
- "with a `result` that conforms EXACTLY to this finite response schema:\n"
- f"{schema_text}\n\n"
- "Schema semantics: `const` is one fixed value; `boolean` is true/false; "
- "`enum` values are the only permitted values; `integer` is an inclusive "
- "bounded range; `object` requires every declared field and no others; "
- "`tuple`/`array` are fixed length; `union` values are "
- '{\"tag\":..., \"value\":...}. Free-form prose is never a valid result.\n'
- "Do not explain your reasoning in the final answer. Never emit anything "
- "except tool calls and the final `finish` call."
- )
-
-
-def _post_json(url: str, payload: dict, headers: dict) -> dict:
- body = json.dumps(payload).encode("utf-8")
- request = urllib.request.Request(url, data=body, method="POST")
- request.add_header("content-type", "application/json")
- for key, value in headers.items():
- request.add_header(key, value)
- with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
- return json.loads(response.read().decode("utf-8"))
-
-
-class OpenAiProfile:
- """Narrow OpenAI-compatible chat-completions loop."""
-
- def __init__(self, endpoint: str, model: str, max_tokens: int) -> None:
- self.url = f"{endpoint}/v1/chat/completions"
- self.model = model
- self.max_tokens = max_tokens
- self.tools = TOOL_DESCRIPTIONS
-
- def initial_messages(self, schema_text: str, task: str) -> list:
- self.tools = tool_descriptions(schema_text)
- return [
- {"role": "system", "content": system_prompt(schema_text)},
- {"role": "user", "content": task},
- ]
-
- def request(self, messages: list, force_finish: bool = False) -> dict:
- payload = {
- "model": self.model,
- "messages": messages,
- "max_tokens": self.max_tokens,
- "tools": [
- {"type": "function", "function": tool} for tool in self.tools
- ],
- }
- if force_finish:
- payload["tool_choice"] = {
- "type": "function",
- "function": {"name": "finish"},
- }
- return _post_json(self.url, payload, {})
-
- def parse(self, response: dict) -> "tuple[list, list]":
- """Returns (assistant message to append, list of (id, name, args))."""
- choices = response.get("choices") or []
- if not choices:
- return [], []
- message = choices[0].get("message") or {}
- calls = []
- for call in message.get("tool_calls") or []:
- function = call.get("function") or {}
- try:
- args = json.loads(function.get("arguments") or "{}")
- except (TypeError, ValueError):
- args = {}
- if not isinstance(args, dict):
- args = {}
- calls.append((call.get("id") or "", function.get("name") or "", args))
- return [message], calls
-
- def tool_result_messages(self, results: list) -> list:
- return [
- {"role": "tool", "tool_call_id": call_id, "content": content}
- for call_id, _name, content in results
- ]
-
- def finish_recovery_messages(self) -> list:
- return [{"role": "user", "content": "Call `finish` now with the finite result."}]
-
- def repository_recovery_messages(self) -> list:
- return [{"role": "user", "content": "Inspect the repository with a read-only tool before answering."}]
-
-
-class AnthropicProfile:
- """Narrow Anthropic-compatible messages loop."""
-
- def __init__(self, endpoint: str, model: str, max_tokens: int) -> None:
- self.url = f"{endpoint}/v1/messages"
- self.model = model
- self.max_tokens = max_tokens
- self.system = ""
- self.tools = TOOL_DESCRIPTIONS
-
- def initial_messages(self, schema_text: str, task: str) -> list:
- self.system = system_prompt(schema_text)
- self.tools = tool_descriptions(schema_text)
- return [{"role": "user", "content": task}]
-
- def request(self, messages: list, force_finish: bool = False) -> dict:
- payload = {
- "model": self.model,
- "system": self.system,
- "messages": messages,
- "max_tokens": self.max_tokens,
- "tools": [
- {
- "name": tool["name"],
- "description": tool["description"],
- "input_schema": tool["parameters"],
- }
- for tool in self.tools
- ],
- }
- if force_finish:
- payload["tool_choice"] = {"type": "tool", "name": "finish"}
- return _post_json(self.url, payload, {"anthropic-version": "2023-06-01"})
-
- def parse(self, response: dict) -> "tuple[list, list]":
- content = response.get("content") or []
- calls = []
- for block in content:
- if block.get("type") == "tool_use":
- args = block.get("input")
- if not isinstance(args, dict):
- args = {}
- calls.append((block.get("id") or "", block.get("name") or "", args))
- return [{"role": "assistant", "content": content}], calls
-
- def tool_result_messages(self, results: list) -> list:
- return [
- {
- "role": "user",
- "content": [
- {"type": "tool_result", "tool_use_id": call_id, "content": content}
- for call_id, _name, content in results
- ],
- }
- ]
-
- def finish_recovery_messages(self) -> list:
- return [{"role": "user", "content": "Call `finish` now with the finite result."}]
-
- def repository_recovery_messages(self) -> list:
- return [{"role": "user", "content": "Inspect the repository with a read-only tool before answering."}]
-
-
-def build_profile(endpoint: str, model: str, max_tokens: int):
- profile = os.environ.get("AWF_BOUNDED_AGENT_PROFILE", "")
- if profile == "anthropic":
- return AnthropicProfile(endpoint, model, max_tokens)
- if profile == "openai":
- return OpenAiProfile(endpoint, model, max_tokens)
- return None
-
-
-def write_result(layout: Layout, value, max_output_bytes: int) -> bool:
- """Writes exactly one JSON value to the dedicated bounded result file."""
- try:
- encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
- except (TypeError, ValueError):
- return False
- if len(encoded) > max_output_bytes:
- return False
- try:
- with open(layout.out_path, "wb") as handle:
- handle.write(encoded)
- except OSError:
- return False
- return True
-
-
-def run(layout: Layout) -> int:
- """Runs one bounded-agent invocation against the given fixed layout."""
- endpoint = os.environ.get("AWF_BOUNDED_AGENT_API_ENDPOINT", "")
- model = os.environ.get("AWF_BOUNDED_AGENT_MODEL", "")
- if not endpoint or not model:
- return _fail(EXIT_CONFIGURATION_INVALID)
-
- max_requests = _env_int("AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS", 8)
- max_tokens = _env_int("AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS", 1024)
- max_output_bytes = _env_int("AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES", 8192)
- deadline = time.monotonic() + _env_int("AWF_BOUNDED_AGENT_DEADLINE_SECONDS", 120)
-
- try:
- task = layout.task_path.read_text(encoding="utf-8")
- schema_text = layout.schema_path.read_text(encoding="utf-8")
- json.loads(schema_text)
- except (OSError, ValueError):
- _session_event(layout, {"event": "failure", "category": "input-invalid"})
- return _fail(EXIT_INPUT_INVALID)
-
- profile = build_profile(endpoint, model, max_tokens)
- if profile is None:
- _session_event(layout, {"event": "failure", "category": "configuration-invalid"})
- return _fail(EXIT_CONFIGURATION_INVALID)
-
- _session_event(layout, {
- "event": "session",
- "profile": os.environ.get("AWF_BOUNDED_AGENT_PROFILE", ""),
- "model": model,
- "task": task,
- "schema": json.loads(schema_text),
- })
- messages = profile.initial_messages(schema_text, task)
- force_finish = False
- repository_tool_called = False
-
- for _ in range(max_requests):
- if time.monotonic() >= deadline:
- _session_event(layout, {"event": "failure", "category": "deadline-exceeded"})
- return _fail(EXIT_DEADLINE_EXCEEDED)
- try:
- response = profile.request(messages, force_finish)
- except urllib.error.HTTPError as error:
- _session_event(layout, {
- "event": "failure",
- "category": "provider-http-error",
- "status": error.code,
- })
- return _fail(EXIT_PROVIDER_HTTP_ERROR)
- except (urllib.error.URLError, OSError, TimeoutError):
- _session_event(layout, {"event": "failure", "category": "provider-transport-error"})
- return _fail(EXIT_PROVIDER_TRANSPORT_ERROR)
- except ValueError:
- _session_event(layout, {"event": "failure", "category": "provider-response-invalid"})
- return _fail(EXIT_PROVIDER_RESPONSE_INVALID)
-
- _session_event(layout, {"event": "provider-response", "response": response})
- appended, calls = profile.parse(response)
- messages.extend(appended)
- if not calls:
- if repository_tool_called:
- messages.extend(profile.finish_recovery_messages())
- force_finish = True
- else:
- messages.extend(profile.repository_recovery_messages())
- force_finish = False
- continue
- force_finish = False
-
- results = []
- for call_id, name, args in calls:
- if name == "finish":
- if not repository_tool_called:
- results.append((call_id, name, "error: inspect repository before finishing"))
- continue
- if write_result(layout, args.get("result"), max_output_bytes):
- _session_event(layout, {"event": "success"})
- return 0
- _session_event(layout, {"event": "failure", "category": "result-write-failed"})
- return _fail(EXIT_RESULT_WRITE_FAILED)
- handler = LOCAL_TOOLS.get(name)
- if handler is None:
- results.append((call_id, name, "error: unknown tool"))
- continue
- repository_tool_called = True
- try:
- output = handler(layout, args)
- except Exception: # noqa: BLE001 - never leak a traceback
- output = "error: tool failed"
- _session_event(layout, {
- "event": "tool-result",
- "callId": call_id,
- "name": name,
- "arguments": args,
- "output": output[:MAX_TOOL_RESULT_BYTES],
- })
- results.append((call_id, name, output[:MAX_TOOL_RESULT_BYTES]))
-
- messages.extend(profile.tool_result_messages(results))
-
- _session_event(layout, {"event": "failure", "category": "model-loop-exhausted"})
- return _fail(EXIT_MODEL_LOOP_EXHAUSTED)
-
-
-def main() -> int:
- """Entry point. Uses only the fixed mount points; nothing is configurable."""
- return run(Layout(SEED_DIR, TASK_PATH, SCHEMA_PATH, OUT_PATH, SESSION_LOG_PATH))
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/containers/bounded-query/bounded-execution/finite-disclosure.js b/containers/bounded-execution/finite-disclosure.js
similarity index 95%
rename from containers/bounded-query/bounded-execution/finite-disclosure.js
rename to containers/bounded-execution/finite-disclosure.js
index bc6fec360..b8ad7b57c 100644
--- a/containers/bounded-query/bounded-execution/finite-disclosure.js
+++ b/containers/bounded-execution/finite-disclosure.js
@@ -7,7 +7,7 @@
* `src/bounded-execution/finite-disclosure.ts`. The broker runs inside its own
* container image and cannot import AWF's TypeScript sources, so the rules are
* restated here and pinned
- * by `src/bounded-query/protocol-parity.test.ts`, which runs the *same* vector
+ * by `src/enclave-script/protocol-parity.test.ts`, which runs the *same* vector
* table through both implementations and fails if they ever diverge.
*
* Do not "improve" one side without the other.
@@ -30,7 +30,7 @@ const MAX_PRIVATE_REPO_LENGTH = 140;
const TIMING_BUCKETS_MS = [10, 100, 1_000, 10_000, 60_000, 600_000];
const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000;
-const MAX_QUERY_TIMEOUT_SECONDS =
+const MAX_ENCLAVE_TIMEOUT_SECONDS =
(TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000;
function ceilLog2(n) {
@@ -40,7 +40,7 @@ function ceilLog2(n) {
const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length);
const RESULT_STATUS_BIT_COST = 1;
-const BOUNDED_QUERY_REPO_PATTERN =
+const PRIVATE_REPOSITORY_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/;
const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
@@ -352,7 +352,7 @@ function cappedSchemaCardinality(schema) {
}
}
-function queryBitsForSchema(schema) {
+function informationChargeForSchema(schema) {
return RESULT_STATUS_BIT_COST + ceilLog2BigInt(cappedSchemaCardinality(schema)) + TIMING_BUCKET_BITS;
}
@@ -586,7 +586,7 @@ function strictParseJson(text) {
// ── Request/result validation and canonical envelopes ───────────────────────
-function validateBoundedQueryRequest(raw) {
+function validateEnclaveScriptRequest(raw) {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return { valid: false, errors: ['request must be a JSON object'] };
}
@@ -600,7 +600,7 @@ function validateBoundedQueryRequest(raw) {
if (typeof privateRepo !== 'string' || privateRepo.length === 0) {
errors.push('privateRepo must be a non-empty string');
- } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) {
+ } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !PRIVATE_REPOSITORY_PATTERN.test(privateRepo)) {
errors.push(
'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)',
);
@@ -629,13 +629,13 @@ function validateBoundedQueryRequest(raw) {
return { valid: true, request: { privateRepo, schema: schemaValidation.schema, script } };
}
-const CANONICAL_ERROR_JSON = '{"status":"error"}';
+const CANONICAL_ERROR_RESPONSE_JSON = '{"status":"error"}';
-function canonicalOkJson(canonicalResultJson) {
+function canonicalSuccessJson(canonicalResultJson) {
return `{"status":"ok","result":${canonicalResultJson}}`;
}
-function parseAndValidateQueryOutput(raw, schema) {
+function parseAndValidateFiniteOutput(raw, schema) {
if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false };
const parsed = strictParseJson(raw);
if (!parsed) return { ok: false };
@@ -659,26 +659,19 @@ module.exports = {
MAX_PRIVATE_REPO_LENGTH,
TIMING_BUCKETS_MS,
FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS,
- MAX_QUERY_TIMEOUT_SECONDS,
+ MAX_ENCLAVE_TIMEOUT_SECONDS,
TIMING_BUCKET_BITS,
RESULT_STATUS_BIT_COST,
- BOUNDED_QUERY_REPO_PATTERN,
- CANONICAL_ERROR_JSON,
+ PRIVATE_REPOSITORY_PATTERN,
+ CANONICAL_ERROR_RESPONSE_JSON,
validateSchema,
ceilLog2BigInt,
schemaCardinality,
- queryBitsForSchema,
+ informationChargeForSchema,
validateValueAgainstSchema,
canonicalizeSchemaValue,
strictParseJson,
- validateBoundedQueryRequest,
- canonicalOkJson,
- parseAndValidateQueryOutput,
- // Reusable bounded-execution names; bounded-query exports above stay stable.
- validateFiniteSchema: validateSchema,
- finiteSchemaCardinality: schemaCardinality,
- informationChargeForSchema: queryBitsForSchema,
- canonicalizeFiniteSchemaValue: canonicalizeSchemaValue,
- canonicalSuccessJson: canonicalOkJson,
- CANONICAL_ERROR_RESPONSE_JSON: CANONICAL_ERROR_JSON,
+ validateEnclaveScriptRequest,
+ canonicalSuccessJson,
+ parseAndValidateFiniteOutput,
};
diff --git a/containers/bounded-query/bounded-execution/fixed-timing.js b/containers/bounded-execution/fixed-timing.js
similarity index 100%
rename from containers/bounded-query/bounded-execution/fixed-timing.js
rename to containers/bounded-execution/fixed-timing.js
diff --git a/containers/bounded-query/bounded-execution/index.js b/containers/bounded-execution/index.js
similarity index 100%
rename from containers/bounded-query/bounded-execution/index.js
rename to containers/bounded-execution/index.js
diff --git a/containers/bounded-query/bounded-execution/protected-audit.js b/containers/bounded-execution/protected-audit.js
similarity index 88%
rename from containers/bounded-query/bounded-execution/protected-audit.js
rename to containers/bounded-execution/protected-audit.js
index cdcbf810a..8dea3264b 100644
--- a/containers/bounded-query/bounded-execution/protected-audit.js
+++ b/containers/bounded-execution/protected-audit.js
@@ -22,8 +22,7 @@ function redactAuditDetail(detail) {
return detail === undefined ? undefined : String(detail).slice(0, MAX_REASON_LENGTH);
}
-/** Default audit filename, kept for bounded-query compatibility. */
-const DEFAULT_AUDIT_FILENAME = 'bounded-query.jsonl';
+const DEFAULT_AUDIT_FILENAME = 'enclave.jsonl';
function createAuditLog(auditDir, fileName = DEFAULT_AUDIT_FILENAME) {
let fd;
@@ -35,7 +34,7 @@ function createAuditLog(auditDir, fileName = DEFAULT_AUDIT_FILENAME) {
// Losing the audit file must not take the broker down; fall back to
// stderr, which is captured by `docker logs` on the broker container
// (also outside the agent's reach).
- process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`);
+ process.stderr.write(`[awf-enclave] audit log unavailable: ${error.message}\n`);
fd = undefined;
}
@@ -48,7 +47,7 @@ function createAuditLog(auditDir, fileName = DEFAULT_AUDIT_FILENAME) {
fs.writeSync(fd, line);
return;
} catch (error) {
- process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`);
+ process.stderr.write(`[awf-enclave] audit log unavailable: ${error.message}\n`);
try {
fs.closeSync(fd);
} catch {
diff --git a/containers/bounded-query/bounded-execution/repository-staging.js b/containers/bounded-execution/repository-staging.js
similarity index 100%
rename from containers/bounded-query/bounded-execution/repository-staging.js
rename to containers/bounded-execution/repository-staging.js
diff --git a/containers/bounded-query/bounded-execution/sensitivity-ledger.js b/containers/bounded-execution/sensitivity-ledger.js
similarity index 100%
rename from containers/bounded-query/bounded-execution/sensitivity-ledger.js
rename to containers/bounded-execution/sensitivity-ledger.js
diff --git a/containers/bounded-query/bounded-execution/sensitivity-policy.js b/containers/bounded-execution/sensitivity-policy.js
similarity index 55%
rename from containers/bounded-query/bounded-execution/sensitivity-policy.js
rename to containers/bounded-execution/sensitivity-policy.js
index 51cf30ee1..a4c2281fc 100644
--- a/containers/bounded-query/bounded-execution/sensitivity-policy.js
+++ b/containers/bounded-execution/sensitivity-policy.js
@@ -2,10 +2,8 @@
/**
* Repository sensitivity categories and their fixed per-run information
- * budgets — broker-side mirror of `ENCLAVE_SENSITIVITY_RUN_BITS` in
- * `src/types/enclave-options.ts`. The bounded-query names below are compatibility
- * aliases while legacy brokers remain live. Kept in a tiny standalone module (not
- * `protocol.js`) because it is config/ledger data, not wire protocol.
+ * budgets — server-side mirror of `ENCLAVE_SENSITIVITY_RUN_BITS` in
+ * `src/types/enclave-options.ts`.
*
* `null` means "unmetered": `public` still runs through the same finite
* schema/result validation and operational limits (`maxInvocations`,
@@ -16,17 +14,15 @@
* repository can never fund a single query and therefore never copies a
* seed or launches Python.
*/
-const BOUNDED_QUERY_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed'];
+const ENCLAVE_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed'];
-const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = {
+const ENCLAVE_SENSITIVITY_RUN_BITS = {
public: null,
internal: 64,
confidential: 8,
sealed: 0,
};
-const ENCLAVE_SENSITIVITIES = BOUNDED_QUERY_SENSITIVITIES;
-const ENCLAVE_SENSITIVITY_RUN_BITS = BOUNDED_QUERY_SENSITIVITY_RUN_BITS;
const ENCLAVE_INFORMATION_BUDGET_POLICY = Object.freeze({
runBits: ENCLAVE_SENSITIVITY_RUN_BITS,
});
@@ -35,8 +31,4 @@ module.exports = {
ENCLAVE_INFORMATION_BUDGET_POLICY,
ENCLAVE_SENSITIVITIES,
ENCLAVE_SENSITIVITY_RUN_BITS,
- BOUNDED_QUERY_SENSITIVITIES,
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
- SENSITIVITY_LEVELS: BOUNDED_QUERY_SENSITIVITIES,
- SENSITIVITY_RUN_BITS: BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
};
diff --git a/containers/bounded-query/Dockerfile b/containers/bounded-query/Dockerfile
deleted file mode 100644
index b8670fecd..000000000
--- a/containers/bounded-query/Dockerfile
+++ /dev/null
@@ -1,86 +0,0 @@
-# Bounded-query image — multi-stage build producing two images:
-#
-# 1. `query` stage — minimal Python 3-only sandbox rootfs published as
-# `bounded-query:*`. The query process gets none of the broker's tools:
-# no Node runtime, no docker-cli, no Alpine package manager. The query
-# runs --read-only, --network none, --cap-drop ALL, unprivileged (UID
-# 65534), and with a restrictive seccomp profile, so the absence of
-# those binaries is defence-in-depth rather than the primary control.
-#
-# 2. `broker` (default) stage — published as `bounded-query-broker:*`.
-# Has Node + docker-cli to run the server and launch query containers.
-#
-# Using two images keeps the query environment minimal while still
-# guaranteeing the query image is local when the broker starts: the release
-# workflow builds and pushes both tags, and the compose service pulls the
-# broker image, which is declared as depending on the query image being
-# present (verified by assertQueryImageAvailable before the first request).
-
-# ──────────────────────────────────────────────────────────────────────────
-# query stage: Python 3 standard-library-only sandbox rootfs.
-# No Node, no docker-cli, no apk package manager.
-# ──────────────────────────────────────────────────────────────────────────
-FROM python:3.12-alpine3.21 AS query
-
-RUN python3 -c 'import json, pathlib, sys; sys.exit(0)' \
- && test -x /usr/local/bin/python3 \
- && rm -f /sbin/apk
-
-COPY query-entrypoint.py /usr/local/bin/run-query
-RUN chmod 0555 /usr/local/bin/run-query
-
-# Pre-create mount points used by the query container so a missing bind
-# mount fails loudly rather than silently materialising an empty directory.
-RUN mkdir -p /query /awf/seed
-
-# ──────────────────────────────────────────────────────────────────────────
-# broker stage: trusted broker with Node + docker-cli (default build target)
-# ──────────────────────────────────────────────────────────────────────────
-FROM node:22.23.2-alpine3.24 AS broker
-
-# docker-cli — used by the broker to launch query containers
-RUN apk add --no-cache docker-cli \
- && test -x /usr/bin/docker
-
-WORKDIR /opt/awf/broker
-COPY broker/ /opt/awf/broker/
-COPY bounded-execution/ /opt/awf/bounded-execution/
-COPY query-seccomp.json /opt/awf/query-seccomp.json
-
-RUN chmod -R a-w /opt/awf \
- && node --check /opt/awf/broker/server.js \
- && node --check /opt/awf/broker/broker.js \
- && node --check /opt/awf/broker/protocol.js \
- && node --check /opt/awf/bounded-execution/finite-disclosure.js \
- && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \
- && node --check /opt/awf/bounded-execution/fixed-timing.js \
- && node --check /opt/awf/bounded-execution/protected-audit.js \
- && node --check /opt/awf/bounded-execution/repository-staging.js \
- && node --check /opt/awf/bounded-execution/index.js \
- && node --check /opt/awf/broker/framing.js \
- && node --check /opt/awf/broker/workspace.js \
- && node --check /opt/awf/broker/query-runner.js \
- && node --check /opt/awf/broker/query-runner-spec.js \
- && node --check /opt/awf/broker/docker-client.js \
- && node --check /opt/awf/broker/docker-query-runner.js \
- && node --check /opt/awf/broker/gvisor-query-runner.js \
- && node --check /opt/awf/broker/sbx-client.js \
- && node --check /opt/awf/broker/sbx-capability-probe.js \
- && node --check /opt/awf/broker/sbx-query-runner-spec.js \
- && node --check /opt/awf/broker/sbx-query-runner.js \
- && node --check /opt/awf/broker/healthcheck.js
-
-# Fixed broker-only mount points.
-RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-bounded-query /run/awf-bounded-query-control /var/log/awf-bounded-query
-
-# The broker is root only to copy host-owned read-only seeds into private
-# workspaces and hand those workspaces to the unprivileged query uid.
-# Keep the default set dropped and restore only those filesystem duties.
-USER root
-
-ENTRYPOINT ["node", "/opt/awf/broker/server.js"]
-
-# The AWF-owned unified enclave MCP server is built from its own Dockerfile
-# (`enclave-mcp/Dockerfile`) with the wider `containers/` build context,
-# because it drives both the bounded-script executor in this directory and the
-# audited bounded-agent enclave executor under `containers/bounded-agent/`.
diff --git a/containers/bounded-query/agent-broker/enclave-runner.js b/containers/bounded-query/agent-broker/enclave-runner.js
deleted file mode 100644
index 025dbadf4..000000000
--- a/containers/bounded-query/agent-broker/enclave-runner.js
+++ /dev/null
@@ -1,13 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
-//
-// The published enclave-mcp-server image receives the real, audited
-// bounded-agent enclave modules at /opt/awf/agent-broker (see
-// bounded-query/enclave-mcp/Dockerfile, which COPYs
-// containers/bounded-agent/broker/ there). This file exists only so the same
-// `../agent-broker/enclave-runner` specifier also resolves when the enclave MCP
-// modules are required directly from the source tree (unit tests,
-// `node --check`), without duplicating a security-critical implementation
-// into a second directory.
-module.exports = require('../../bounded-agent/broker/enclave-runner');
diff --git a/containers/bounded-query/agent-broker/framing.js b/containers/bounded-query/agent-broker/framing.js
deleted file mode 100644
index ca4b66503..000000000
--- a/containers/bounded-query/agent-broker/framing.js
+++ /dev/null
@@ -1,13 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
-//
-// The published enclave-mcp-server image receives the real, audited
-// bounded-agent enclave modules at /opt/awf/agent-broker (see
-// bounded-query/enclave-mcp/Dockerfile, which COPYs
-// containers/bounded-agent/broker/ there). This file exists only so the same
-// `../agent-broker/framing` specifier also resolves when the enclave MCP
-// modules are required directly from the source tree (unit tests,
-// `node --check`), without duplicating a security-critical implementation
-// into a second directory.
-module.exports = require('../../bounded-agent/broker/framing');
diff --git a/containers/bounded-query/agent-broker/workspace.js b/containers/bounded-query/agent-broker/workspace.js
deleted file mode 100644
index 748b6ac16..000000000
--- a/containers/bounded-query/agent-broker/workspace.js
+++ /dev/null
@@ -1,13 +0,0 @@
-'use strict';
-
-// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
-//
-// The published enclave-mcp-server image receives the real, audited
-// bounded-agent enclave modules at /opt/awf/agent-broker (see
-// bounded-query/enclave-mcp/Dockerfile, which COPYs
-// containers/bounded-agent/broker/ there). This file exists only so the same
-// `../agent-broker/workspace` specifier also resolves when the enclave MCP
-// modules are required directly from the source tree (unit tests,
-// `node --check`), without duplicating a security-critical implementation
-// into a second directory.
-module.exports = require('../../bounded-agent/broker/workspace');
diff --git a/containers/bounded-query/broker/audit.js b/containers/bounded-query/broker/audit.js
deleted file mode 100644
index e792659f2..000000000
--- a/containers/bounded-query/broker/audit.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-query compatibility entrypoint.
-module.exports = require('../bounded-execution/protected-audit');
diff --git a/containers/bounded-query/broker/config.js b/containers/bounded-query/broker/config.js
deleted file mode 100644
index 45838b207..000000000
--- a/containers/bounded-query/broker/config.js
+++ /dev/null
@@ -1,168 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const { MAX_QUERY_TIMEOUT_SECONDS } = require('./protocol');
-const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity');
-const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging');
-
-/**
- * Broker configuration.
- *
- * Everything here is supplied by AWF through the container environment and
- * fixed mount points. Nothing in this file is influenced by a query request:
- * the caller cannot choose an image, a runtime, a path, a mount, a limit, or
- * a timeout.
- */
-
-const SEEDS_DIR = '/srv/awf/seeds';
-const WORK_DIR = '/srv/awf/work';
-const SEED_MAP_PATH = '/srv/awf/seed-map.json';
-const SOCKET_DIR = '/run/awf-bounded-query';
-const SOCKET_PATH = path.join(SOCKET_DIR, 'broker.sock');
-const CONTROL_DIR = '/run/awf-bounded-query-control';
-const AUDIT_DIR = '/var/log/awf-bounded-query';
-/** Broker-private readiness marker; the control directory is never agent-mounted. */
-const READY_PATH = path.join(CONTROL_DIR, 'broker.ready');
-const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json');
-const QUERY_SECCOMP_PATH = '/opt/awf/query-seccomp.json';
-
-/** Mount points inside the query container. Fixed, never caller-supplied. */
-const QUERY_MOUNT_DIR = '/query';
-const QUERY_SCRIPT_PATH = '/awf/query-script.py';
-
-/** Unprivileged uid/gid the query process runs as. */
-const QUERY_UID = 65534;
-const QUERY_GID = 65534;
-
-function requireEnv(name) {
- const value = process.env[name];
- if (!value || value.length === 0) {
- throw new Error(`Missing required environment variable: ${name}`);
- }
- return value;
-}
-
-function parsePositiveInt(name, fallback) {
- const raw = process.env[name];
- if (raw === undefined || raw === '') return fallback;
- const parsed = Number.parseInt(raw, 10);
- if (!Number.isInteger(parsed) || parsed < 1) {
- throw new Error(`Environment variable ${name} must be a positive integer`);
- }
-
- return parsed;
-}
-
-function loadSbxIngressCapabilities(capabilityPath) {
- const parsed = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
- const pattern = /^[0-9a-f]{64}$/;
- if (
- !parsed
- || parsed.version !== 1
- || typeof parsed.query !== 'string'
- || typeof parsed.probe !== 'string'
- || !pattern.test(parsed.query)
- || !pattern.test(parsed.probe)
- || parsed.query === parsed.probe
- ) {
- throw new Error('SBX ingress capability file is malformed');
- }
- return { query: parsed.query, probe: parsed.probe };
-}
-
-/**
- * Parses the per-invocation timeout, additionally re-enforcing (defense in
- * depth; AWF's host-side preflight already rejects an out-of-range value
- * before this container ever starts) that it preserves the final response
- * bucket's post-processing margin.
- */
-function parseTimeoutSeconds() {
- const parsed = parsePositiveInt('AWF_BOUNDED_QUERY_TIMEOUT', 30);
- if (parsed > MAX_QUERY_TIMEOUT_SECONDS) {
- throw new Error(
- `Environment variable AWF_BOUNDED_QUERY_TIMEOUT must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` +
- '(the final response bucket reserves one minute for termination, validation, and cleanup)',
- );
- }
- return parsed;
-}
-
-function loadConfig() {
- const memoryLimit = process.env.AWF_BOUNDED_QUERY_MEMORY || '512m';
- if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(memoryLimit)) {
- throw new Error('AWF_BOUNDED_QUERY_MEMORY must be a Docker memory limit (e.g. "512m")');
- }
-
- const queryBackend = requireEnv('AWF_BOUNDED_QUERY_BACKEND');
- if (queryBackend !== 'docker' && queryBackend !== 'gvisor' && queryBackend !== 'sbx') {
- throw new Error(`Unsupported AWF_BOUNDED_QUERY_BACKEND: ${queryBackend}`);
- }
- const primaryBackend = requireEnv('AWF_BOUNDED_QUERY_PRIMARY_BACKEND');
- if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') {
- throw new Error(`Unsupported AWF_BOUNDED_QUERY_PRIMARY_BACKEND: ${primaryBackend}`);
- }
-
- const tcpPortRaw = process.env.AWF_BOUNDED_QUERY_TCP_PORT;
- const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_QUERY_TCP_PORT');
- if (tcpPort !== undefined && tcpPort > 65535) {
- throw new Error('AWF_BOUNDED_QUERY_TCP_PORT must be a valid TCP port');
- }
-
- const hostWorkDir = requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR');
- const sbxWorkDir = queryBackend === 'sbx'
- ? requireEnv('AWF_BOUNDED_QUERY_SBX_WORK_DIR')
- : undefined;
-
- return {
- seedsDir: SEEDS_DIR,
- workDir: WORK_DIR,
- seedMapPath: SEED_MAP_PATH,
- socketDir: SOCKET_DIR,
- socketPath: SOCKET_PATH,
- controlDir: CONTROL_DIR,
- readyPath: READY_PATH,
- auditDir: AUDIT_DIR,
- querySeccompPath: QUERY_SECCOMP_PATH,
- queryMountDir: QUERY_MOUNT_DIR,
- queryScriptPath: QUERY_SCRIPT_PATH,
- queryUid: QUERY_UID,
- queryGid: QUERY_GID,
- queryImage: requireEnv('AWF_BOUNDED_QUERY_IMAGE'),
- // The daemon resolves query bind-mount sources in *its* filesystem view,
- // which is not necessarily the broker's (ARC/DinD split filesystems).
- hostWorkDir,
- // sbx and Docker daemons can have different filesystem namespaces (ARC/DinD).
- // Never reuse the Docker-daemon-visible path for sbx mounts.
- sbxWorkDir,
- queryBackend,
- primaryBackend,
- timeoutSeconds: parseTimeoutSeconds(),
- maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32),
- memoryLimit,
- socketUid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_UID', 0),
- socketGid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_GID', 0),
- tcpPort,
- sbxIngressCapabilities: tcpPort === undefined
- ? undefined
- : loadSbxIngressCapabilities(SBX_CAPABILITY_PATH),
- };
-}
-
-/**
- * Loads the AWF-generated repo → { opaque seed id, sensitivity } map.
- *
- * The map is the *only* way a repository can be selected: a request supplies
- * a normalized `owner/repo` id, which is looked up here. Callers never supply
- * a path, and an unknown id is simply absent from the map. Sensitivity is
- * carried in the (AWF-trusted, host-written) map itself, never accepted from
- * a request — a request cannot choose or override its repository's budget.
- */
-function loadSeedMap(seedMapPath) {
- return parsePrivateRepositorySeedMap(
- fs.readFileSync(seedMapPath, 'utf8'),
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
- );
-}
-
-module.exports = { READY_PATH, SBX_CAPABILITY_PATH, loadConfig, loadSeedMap, loadSbxIngressCapabilities };
diff --git a/containers/bounded-query/broker/framing.js b/containers/bounded-query/broker/framing.js
deleted file mode 100644
index d446b490e..000000000
--- a/containers/bounded-query/broker/framing.js
+++ /dev/null
@@ -1,173 +0,0 @@
-'use strict';
-
-const { MAX_SCHEMA_BYTES, MAX_SCRIPT_BYTES, strictParseJson } = require('./protocol');
-
-/** A peer that stops sending a request body cannot pin a broker connection. */
-const BODY_READ_TIMEOUT_MS = 5_000;
-
-/**
- * Wire framing for the agent → broker request (protocol v2).
- *
- * The request is deliberately *not* caller-supplied JSON at the transport
- * level: the agent-facing wrapper is a POSIX shell script, and asking it to
- * emit correct JSON for arbitrary script bytes would be both fragile and an
- * unnecessary parser on the untrusted path. Instead the scalar/JSON fields
- * travel as fixed headers and the script travels as the raw body, and the
- * broker assembles the canonical `{privateRepo, schema, script}` request
- * object itself.
- *
- * The schema travels base64url-encoded in a header (not the body) because
- * HTTP header values are restricted to a printable-ASCII-ish subset, while a
- * `const`/`enum` schema literal may contain arbitrary non-control UTF-8. The
- * assembled object is then validated by the shared protocol rules
- * (`validateBoundedQueryRequest`), so this framing layer adds no new degrees
- * of freedom — it only assembles the object and enforces cheap size/shape
- * bounds before that shared validation runs.
- */
-
-/** Supported request framing version. */
-const QUERY_PROTOCOL_VERSION = '2';
-
-const VERSION_HEADER = 'x-awf-query-version';
-const REPO_HEADER = 'x-awf-repo';
-const SCHEMA_HEADER = 'x-awf-schema-b64';
-
-/** Every header the broker accepts. Anything else is a rejected control. */
-const ALLOWED_AWF_HEADERS = new Set([VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER]);
-
-/** Base64url alphabet only (no padding, no `+`/`/`). */
-const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
-
-/** Generous ceiling on the encoded header length for a schema of at most `MAX_SCHEMA_BYTES`. */
-const MAX_SCHEMA_HEADER_LENGTH = Math.ceil((MAX_SCHEMA_BYTES * 4) / 3) + 4;
-
-/**
- * Rejects duplicated or unexpected `x-awf-*` headers.
- *
- * Duplicates matter because Node joins repeated headers with `", "`, which
- * would silently corrupt a base64url value or a repo slug.
- */
-function validateRawHeaders(rawHeaders) {
- const seen = new Set();
- for (let i = 0; i < rawHeaders.length; i += 2) {
- const name = rawHeaders[i].toLowerCase();
- if (!name.startsWith('x-awf-')) continue;
- if (!ALLOWED_AWF_HEADERS.has(name)) {
- return `unsupported request control header: ${name}`;
- }
- if (seen.has(name)) {
- return `duplicate request header: ${name}`;
- }
- seen.add(name);
- }
- return undefined;
-}
-
-/** Decodes and UTF-8-validates the base64url schema header. */
-function decodeSchemaHeader(value) {
- if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SCHEMA_HEADER_LENGTH) {
- return undefined;
- }
- if (!BASE64URL_PATTERN.test(value)) return undefined;
-
- let decoded;
- try {
- decoded = Buffer.from(value, 'base64url');
- } catch {
- return undefined;
- }
- const text = decoded.toString('utf8');
- // Reject anything that was not valid UTF-8 to begin with (round-trip check).
- if (!Buffer.from(text, 'utf8').equals(decoded)) return undefined;
- return text;
-}
-
-/**
- * Assembles the canonical request object from a framed HTTP request.
- *
- * @returns `{ request }` on success or `{ error }` with a protected reason.
- */
-function buildRequestFromFrame(headers, rawHeaders, script) {
- const headerError = validateRawHeaders(rawHeaders);
- if (headerError) return { error: headerError };
-
- if (headers[VERSION_HEADER] !== QUERY_PROTOCOL_VERSION) {
- return { error: 'unsupported or missing protocol version' };
- }
-
- const privateRepo = headers[REPO_HEADER];
- if (typeof privateRepo !== 'string') {
- return { error: 'missing repository selector' };
- }
-
- const schemaText = decodeSchemaHeader(headers[SCHEMA_HEADER]);
- if (schemaText === undefined) {
- return { error: 'missing or malformed schema header' };
- }
-
- const parsedSchema = strictParseJson(schemaText);
- if (!parsedSchema) {
- return { error: 'schema header is not valid JSON' };
- }
-
- return { request: { privateRepo, schema: parsedSchema.value, script } };
-}
-
-/**
- * Reads the request body, refusing anything above the script cap.
- *
- * The cap is enforced while streaming so an oversized body is never buffered.
- */
-function readBoundedBody(req) {
- return new Promise((resolve) => {
- const chunks = [];
- let total = 0;
- let settled = false;
- const timer = setTimeout(() => {
- req.pause();
- finish({ error: 'request body deadline exceeded' });
- }, BODY_READ_TIMEOUT_MS);
- timer.unref();
-
- const finish = (value) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- resolve(value);
- };
-
- req.on('data', (chunk) => {
- total += chunk.length;
- if (total > MAX_SCRIPT_BYTES) {
- // Stop buffering immediately so an oversized body cannot exhaust
- // memory. The request is paused rather than destroyed so the caller
- // can still write the canonical error response; Node closes the
- // socket once that response is flushed.
- finish({ error: 'script exceeds maximum size' });
- req.pause();
- return;
- }
- chunks.push(chunk);
- });
- req.on('end', () => {
- const body = Buffer.concat(chunks);
- const text = body.toString('utf8');
- if (!Buffer.from(text, 'utf8').equals(body)) {
- finish({ error: 'script is not valid UTF-8' });
- return;
- }
- finish({ script: text });
- });
- req.on('error', () => finish({ error: 'request stream error' }));
- });
-}
-
-module.exports = {
- QUERY_PROTOCOL_VERSION,
- VERSION_HEADER,
- REPO_HEADER,
- SCHEMA_HEADER,
- buildRequestFromFrame,
- readBoundedBody,
- BODY_READ_TIMEOUT_MS,
-};
diff --git a/containers/bounded-query/broker/healthcheck.js b/containers/bounded-query/broker/healthcheck.js
deleted file mode 100644
index 0323996bd..000000000
--- a/containers/bounded-query/broker/healthcheck.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const { READY_PATH } = require('./config');
-
-/**
- * Compose healthcheck for the broker.
- *
- * Checks for the broker-internal ready file written by `main()` in server.js
- * once the socket is accepting connections. This avoids hitting the
- * agent-visible `/query` socket, which has only one route and no health
- * endpoint. Exits non-zero if the ready file is absent or unreadable.
- */
-
-try {
- fs.accessSync(READY_PATH, fs.constants.F_OK);
- process.exit(0);
-} catch {
- process.exit(1);
-}
diff --git a/containers/bounded-query/broker/ledger.js b/containers/bounded-query/broker/ledger.js
deleted file mode 100644
index 533d44960..000000000
--- a/containers/bounded-query/broker/ledger.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-query compatibility entrypoint.
-module.exports = require('../bounded-execution/sensitivity-ledger');
diff --git a/containers/bounded-query/broker/protocol.js b/containers/bounded-query/broker/protocol.js
deleted file mode 100644
index d2362bdbe..000000000
--- a/containers/bounded-query/broker/protocol.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-query compatibility entrypoint.
-module.exports = require('../bounded-execution/finite-disclosure');
diff --git a/containers/bounded-query/broker/query-runner.js b/containers/bounded-query/broker/query-runner.js
deleted file mode 100644
index 7ca96f672..000000000
--- a/containers/bounded-query/broker/query-runner.js
+++ /dev/null
@@ -1,55 +0,0 @@
-'use strict';
-
-const { DockerQueryRunner } = require('./docker-query-runner');
-const { GvisorQueryRunner } = require('./gvisor-query-runner');
-const { SbxQueryRunner } = require('./sbx-query-runner');
-const {
- QUERY_MAX_FILE_BYTES,
- QUERY_WORKSPACE_TMPFS_BYTES,
- buildQueryArgs,
- deriveQueryContainerSpec,
- normalizeTimeoutMs,
-} = require('./query-runner-spec');
-
-/**
- * Trusted broker interface for one-query-per-sandbox execution.
- *
- * @typedef {object} QueryRunner
- * @property {() => Promise} assertAvailable
- * @property {(runId: string) => Promise} reconcileRun
- * @property {(params: {
- * runId: string,
- * invocationId: string,
- * timeoutMs?: number
- * }) => Promise<{exitCode: number, timedOut: boolean, stdout: string, stderr: string}>} runQueryContainer
- */
-
-/**
- * Selects a runner only from AWF's normalized broker configuration.
- *
- * Unknown values fail closed. In particular, gVisor never falls back to the
- * daemon's default OCI runtime when runsc is unavailable.
- *
- * @returns {QueryRunner}
- */
-function createQueryRunner(config, deps = {}) {
- if (config.queryBackend === 'docker') {
- return new DockerQueryRunner(config, deps);
- }
- if (config.queryBackend === 'gvisor') {
- return new GvisorQueryRunner(config, deps);
- }
- if (config.queryBackend === 'sbx') {
- return new SbxQueryRunner(config, deps);
- }
- throw new Error(`Unsupported bounded-query backend: ${config.queryBackend}`);
-}
-
-module.exports = {
- QUERY_MAX_FILE_BYTES,
- QUERY_WORKSPACE_TMPFS_BYTES,
- buildQueryArgs,
- createQueryRunner,
- deriveQueryContainerSpec,
- normalizeTimeoutMs,
-};
diff --git a/containers/bounded-query/broker/scheduler.js b/containers/bounded-query/broker/scheduler.js
deleted file mode 100644
index 14642fbe7..000000000
--- a/containers/bounded-query/broker/scheduler.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-query compatibility entrypoint.
-module.exports = require('../bounded-execution/fixed-timing');
diff --git a/containers/bounded-query/broker/sensitivity.js b/containers/bounded-query/broker/sensitivity.js
deleted file mode 100644
index a50223306..000000000
--- a/containers/bounded-query/broker/sensitivity.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-// Stable bounded-query compatibility entrypoint.
-module.exports = require('../bounded-execution/sensitivity-policy');
diff --git a/containers/bounded-query/broker/server.js b/containers/bounded-query/broker/server.js
deleted file mode 100644
index fd8176aea..000000000
--- a/containers/bounded-query/broker/server.js
+++ /dev/null
@@ -1,395 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const crypto = require('crypto');
-const http = require('http');
-const { createAuditLog } = require('./audit');
-const { createBroker } = require('./broker');
-const { loadConfig, loadSeedMap } = require('./config');
-const { buildRequestFromFrame, readBoundedBody } = require('./framing');
-const { CANONICAL_ERROR_JSON } = require('./protocol');
-const { createQueryRunner } = require('./query-runner');
-const { createRuntimeTelemetry } = require('./runtime-telemetry');
-
-/**
- * Bounded-query broker server.
- *
- * Compose agents use a Unix domain socket. sbx agents use the same protocol
- * over authenticated HTTP only when a disposable capability probe proves that
- * sbx cannot connect through a mounted host socket. In that mode the broker is
- * attached only to a dedicated internal Docker network and published on an
- * ephemeral host-gateway-only port.
- *
- * One route exists:
- * POST /query the bounded-query API
- *
- * The agent-visible socket has no `/health` route. The compose healthcheck
- * instead polls for a broker-internal ready file written by `main()` after
- * the socket starts accepting connections. This removes a distinguishable
- * extra response (the health status body) from the agent-observable surface.
- *
- * `/query` always answers `200` with a canonical result body: `{"status":
- * "ok","result":}` or `{"status":"error"}` — status code and headers
- * are identical either way, and every failure class collapses to the same
- * error body. For any invocation that reached workspace creation, the
- * response is additionally held until a fixed timing-bucket boundary (see
- * `./scheduler`) before being sent, so response latency does not leak
- * unbucketed secret-dependent signal either.
- */
-
-const RESULT_HEADERS = {
- 'content-type': 'application/json',
- 'cache-control': 'no-store',
-};
-// Give a nearly-complete invocation a chance to finish broker cleanup before
-// force-removing this run's containers. Longer queries are interrupted so
-// Compose shutdown remains bounded; host teardown owns private-root removal.
-const SHUTDOWN_GRACE_MS = 1_000;
-const MAX_HEADER_BYTES = 8 * 1024;
-const MAX_CONNECTIONS = 32;
-const PROBE_RESPONSE_DELAY_MS = 10;
-
-function sendResult(res, body) {
- res.writeHead(200, { ...RESULT_HEADERS, 'content-length': Buffer.byteLength(body) });
- res.end(body);
-}
-
-function canonicalRawResponse() {
- return [
- 'HTTP/1.1 200 OK',
- 'content-type: application/json',
- 'cache-control: no-store',
- `content-length: ${Buffer.byteLength(CANONICAL_ERROR_JSON)}`,
- 'connection: close',
- '',
- CANONICAL_ERROR_JSON,
- ].join('\r\n');
-}
-
-function createHardenedServer(listener, audit) {
- let accepting = true;
- let pendingAdmissions = 0;
- const admissionWaiters = [];
- const resolveAdmissionWaiters = () => {
- if (pendingAdmissions !== 0) return;
- while (admissionWaiters.length > 0) {
- admissionWaiters.shift()();
- }
- };
-
- const server = http.createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (req, res) => {
- if (!accepting) {
- sendResult(res, CANONICAL_ERROR_JSON);
- req.resume();
- return;
- }
-
- pendingAdmissions += 1;
- Promise.resolve(listener(req, res, () => accepting))
- .catch((error) => {
- audit.failure('server', 'unhandled-error', error && error.message);
- if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON);
- })
- .finally(() => {
- pendingAdmissions -= 1;
- resolveAdmissionWaiters();
- });
- });
- server.headersTimeout = 5_000;
- server.requestTimeout = 0;
- server.keepAliveTimeout = 1_000;
- server.maxRequestsPerSocket = 1;
-
- let activeConnections = 0;
- server.on('connection', (socket) => {
- activeConnections += 1;
- socket.once('close', () => {
- activeConnections -= 1;
- });
- if (activeConnections > MAX_CONNECTIONS) {
- socket.awfRejected = true;
- audit.failure('transport', 'connection-limit');
- socket.pause();
- socket.end(canonicalRawResponse());
- }
- });
- server.on('clientError', (error, socket) => {
- audit.failure('framing', 'header-rejected', error && error.message);
- setTimeout(() => {
- if (socket.writable) socket.end(canonicalRawResponse());
- }, PROBE_RESPONSE_DELAY_MS);
- });
- server.freezeAdmissions = () => {
- accepting = false;
- };
- server.drainAdmissions = () => (
- pendingAdmissions === 0
- ? Promise.resolve()
- : new Promise((resolve) => admissionWaiters.push(resolve))
- );
- return server;
-}
-
-function processRequest(
- req,
- res,
- broker,
- audit,
- framedHeaders = req.headers,
- framedRawHeaders = req.rawHeaders,
- isAccepting = () => true,
-) {
- if (req.socket.awfRejected) {
- req.resume();
- res.destroy();
- return Promise.resolve();
- }
- if (req.method !== 'POST' || req.url !== '/query') {
- sendResult(res, CANONICAL_ERROR_JSON);
- req.resume();
- return Promise.resolve();
- }
-
- return readBoundedBody(req)
- .then((body) => {
- if (!isAccepting()) {
- sendResult(res, CANONICAL_ERROR_JSON);
- return;
- }
-
- if (body.error !== undefined) {
- audit.failure('framing', 'body-rejected', body.error);
- return broker.handle(undefined, (result) => sendResult(res, result));
- }
-
- const framed = buildRequestFromFrame(framedHeaders, framedRawHeaders, body.script);
- if (framed.error !== undefined) {
- audit.failure('framing', 'frame-rejected', framed.error);
- return broker.handle(undefined, (result) => sendResult(res, result));
- }
-
- return broker.handle(framed.request, (result) => sendResult(res, result));
- })
- .catch((error) => {
- audit.failure('server', 'unhandled-error', error && error.message);
- if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON);
- });
-}
-
-function createServer(deps) {
- const { broker, audit } = deps;
- return createHardenedServer(
- (req, res, isAccepting) => processRequest(
- req,
- res,
- broker,
- audit,
- req.headers,
- req.rawHeaders,
- isAccepting,
- ),
- audit,
- );
-}
-
-function safeCapabilityEquals(actual, expected) {
- if (typeof actual !== 'string') return false;
- const actualBytes = Buffer.from(actual, 'utf8');
- const expectedBytes = Buffer.from(expected, 'utf8');
- return actualBytes.length === expectedBytes.length
- && crypto.timingSafeEqual(actualBytes, expectedBytes);
-}
-
-function stripCapabilityHeader(req) {
- const headers = { ...req.headers };
- delete headers['x-awf-capability'];
- const rawHeaders = [];
- for (let i = 0; i < req.rawHeaders.length; i += 2) {
- if (req.rawHeaders[i].toLowerCase() === 'x-awf-capability') continue;
- rawHeaders.push(req.rawHeaders[i], req.rawHeaders[i + 1]);
- }
- return { headers, rawHeaders };
-}
-
-function createTcpServer(deps) {
- const { broker, audit, capabilities } = deps;
- let probeAvailable = true;
- return createHardenedServer((req, res, isAccepting) => {
- const capabilityHeaders = req.rawHeaders.filter(
- (_value, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'x-awf-capability',
- );
- const supplied = req.headers['x-awf-capability'];
- const isQuery = capabilityHeaders.length === 1 && safeCapabilityEquals(supplied, capabilities.query);
- const isProbe = (
- probeAvailable
- && capabilityHeaders.length === 1
- && safeCapabilityEquals(supplied, capabilities.probe)
- );
-
- if (isProbe) {
- probeAvailable = false;
- audit.lifecycle('sbx-ingress-probe');
- req.resume();
- return new Promise((resolve) => {
- setTimeout(() => {
- sendResult(res, CANONICAL_ERROR_JSON);
- resolve();
- }, PROBE_RESPONSE_DELAY_MS);
- });
- }
-
- if (!isQuery) {
- audit.failure('transport', 'auth-rejected');
- req.resume();
- sendResult(res, CANONICAL_ERROR_JSON);
- return Promise.resolve();
- }
-
- const framed = stripCapabilityHeader(req);
- return processRequest(
- req,
- res,
- broker,
- audit,
- framed.headers,
- framed.rawHeaders,
- isAccepting,
- );
- }, audit);
-}
-
-function listenOnSocket(server, config, audit) {
- fs.rmSync(config.socketPath, { force: true });
- fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o770 });
-
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(config.socketPath, () => {
- try {
- // The agent runs as the host user; hand it the socket explicitly
- // rather than making the socket world-writable.
- fs.chownSync(config.socketPath, config.socketUid, config.socketGid);
- fs.chmodSync(config.socketPath, 0o660);
- } catch (error) {
- audit.lifecycle('socket-ownership-fallback', error.message);
- fs.chmodSync(config.socketPath, 0o666);
- }
-
- resolve();
- });
- });
-}
-
-function listenOnTcp(server, config) {
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(config.tcpPort, '0.0.0.0', resolve);
- });
-}
-
-async function main() {
- const config = loadConfig();
- const audit = createAuditLog(config.auditDir);
- const telemetry = createRuntimeTelemetry(config.auditDir);
- const { runId, seeds } = loadSeedMap(config.seedMapPath);
- const runner = createQueryRunner(config);
-
- // Fail closed before accepting requests and reconcile containers left by a
- // prior broker process for this exact run. Queries never pull or fall back.
- await runner.assertAvailable();
- await runner.reconcileRun(runId);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- queryBackend: config.queryBackend,
- lifecycleClass: 'startup',
- capabilityState: 'supported',
- category: 'ready',
- });
-
- const broker = createBroker({ config, seedMap: seeds, runId, audit, runner, telemetry });
- const unixServer = createServer({ broker, audit });
- const servers = [unixServer];
-
- await listenOnSocket(unixServer, config, audit);
- if (config.tcpPort !== undefined) {
- const tcpServer = createTcpServer({
- broker,
- audit,
- capabilities: config.sbxIngressCapabilities,
- });
- await listenOnTcp(tcpServer, config);
- servers.push(tcpServer);
- }
-
- // Write the ready file AFTER the socket is accepting connections. The
- // compose healthcheck polls this file in the broker-only control mount.
- fs.mkdirSync(config.controlDir, { recursive: true, mode: 0o700 });
- fs.writeFileSync(config.readyPath, '', { mode: 0o644 });
-
- audit.lifecycle('listening', {
- socket: config.socketPath,
- repos: seeds.size,
- backend: config.queryBackend,
- ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http',
- maxInvocations: config.maxInvocations,
- });
-
- let shuttingDown = false;
- const shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
- broker.close();
- for (const server of servers) {
- server.freezeAdmissions();
- server.close();
- }
- const forcedExit = setTimeout(() => process.exit(1), 5000);
- forcedExit.unref();
- try {
- await Promise.race([
- Promise.all([
- ...servers.map((server) => server.drainAdmissions()),
- broker.drain(),
- ]),
- new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS)),
- ]);
- await runner.reconcileRun(runId);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- queryBackend: config.queryBackend,
- lifecycleClass: 'cleanup',
- capabilityState: 'supported',
- category: 'success',
- });
- process.exit(0);
- } catch (error) {
- audit.lifecycle('shutdown-cleanup-failed', error.message);
- telemetry.emit({
- primaryBackend: config.primaryBackend,
- queryBackend: config.queryBackend,
- lifecycleClass: 'cleanup',
- capabilityState: 'supported',
- category: 'cleanup-failed',
- });
- process.exit(1);
- }
- };
- process.on('SIGTERM', shutdown);
- process.on('SIGINT', shutdown);
-}
-
-if (require.main === module) {
- main().catch((error) => {
- process.stderr.write(`[bounded-query] broker failed to start: ${error.message}\n`);
- process.exit(1);
- });
-}
-
-module.exports = {
- createServer,
- createTcpServer,
- listenOnSocket,
- listenOnTcp,
- MAX_HEADER_BYTES,
- MAX_CONNECTIONS,
-};
diff --git a/containers/bounded-query/enclave-mcp/Dockerfile b/containers/bounded-query/enclave-mcp/Dockerfile
deleted file mode 100644
index 8f5b92672..000000000
--- a/containers/bounded-query/enclave-mcp/Dockerfile
+++ /dev/null
@@ -1,81 +0,0 @@
-# AWF unified enclave MCP server image.
-#
-# This image owns the Docker socket and the private seed/work/audit mounts for
-# *both* enclave executors, and its Compose service always runs with
-# only the dedicated internal MCP control network. It has no `awf-net`, enclave
-# executor network, Squid, host gateway, published port, or external egress. Its
-# only caller-facing surface is authenticated streamable HTTP through mcpg.
-#
-# BUILD CONTEXT: `containers/` (not `containers/bounded-query/`). The server
-# drives two audited executors that live in two directories:
-#
-# * the bounded-script sandbox pipeline under `containers/bounded-query/`
-# * the bounded-agent enclave pipeline under `containers/bounded-agent/`
-#
-# A wider context is preferred over duplicating a security-critical
-# implementation into a third source tree.
-#
-# docker build -f bounded-query/enclave-mcp/Dockerfile containers/
-#
-# The executor sandboxes themselves are separate, minimal images
-# (`enclave-script`, `enclave-agent`); nothing in this image ever executes
-# caller-supplied code.
-
-FROM node:22.23.2-alpine3.24 AS enclave-mcp-server
-
-# docker-cli — used by the server to launch single-use executor containers.
-RUN apk add --no-cache docker-cli \
- && test -x /usr/bin/docker
-
-WORKDIR /opt/awf/enclave-mcp
-
-# Shared bounded-execution foundation (finite schema algebra, bit charge,
-# strict JSON parsing/canonicalization, fixed timing buckets, protected audit,
-# seed-map parsing, sensitivity policy and ledger).
-COPY bounded-query/bounded-execution/ /opt/awf/bounded-execution/
-# Bounded-script executor pipeline (workspace, runner, runner spec, runtimes).
-COPY bounded-query/broker/ /opt/awf/broker/
-# Bounded-agent enclave pipeline, reused verbatim from the audited
-# bounded-agent broker rather than copied into a second implementation.
-COPY bounded-agent/broker/ /opt/awf/agent-broker/
-# The MCP protocol/server and the executor adapters.
-COPY bounded-query/enclave-mcp/ /opt/awf/enclave-mcp/
-# One audited no-network sandbox seccomp profile, pinned for both executors.
-COPY bounded-query/query-seccomp.json /opt/awf/query-seccomp.json
-COPY bounded-query/query-seccomp.json /opt/awf/enclave-seccomp.json
-
-RUN rm -f /opt/awf/enclave-mcp/Dockerfile \
- && chmod -R a-w /opt/awf \
- && node --check /opt/awf/enclave-mcp/config.js \
- && node --check /opt/awf/enclave-mcp/mcp-protocol.js \
- && node --check /opt/awf/enclave-mcp/agent-executor.js \
- && node --check /opt/awf/enclave-mcp/server.js \
- && node --check /opt/awf/enclave-mcp/healthcheck.js \
- && node --check /opt/awf/broker/broker.js \
- && node --check /opt/awf/broker/query-runner.js \
- && node --check /opt/awf/broker/query-runner-spec.js \
- && node --check /opt/awf/broker/workspace.js \
- && node --check /opt/awf/agent-broker/enclave-runner.js \
- && node --check /opt/awf/agent-broker/enclave-runner-spec.js \
- && node --check /opt/awf/agent-broker/docker-enclave-runner.js \
- && node --check /opt/awf/agent-broker/gvisor-enclave-runner.js \
- && node --check /opt/awf/agent-broker/framing.js \
- && node --check /opt/awf/agent-broker/workspace.js \
- && node --check /opt/awf/bounded-execution/finite-disclosure.js \
- && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \
- && node --check /opt/awf/bounded-execution/fixed-timing.js \
- && node --check /opt/awf/bounded-execution/protected-audit.js \
- && node --check /opt/awf/bounded-execution/repository-staging.js \
- && node -e "require('/opt/awf/enclave-mcp/agent-executor.js')"
-
-# Fixed server-only mount points.
-RUN mkdir -p /srv/awf/seeds /srv/awf/work \
- /run/awf-enclave-mcp /run/awf-enclave-mcp-control /var/log/awf-enclave
-
-# The server is root only to copy host-owned read-only seeds into private
-# workspaces and hand those workspaces to the unprivileged executor uid.
-# Compose keeps the default capability set dropped and restores only those
-# filesystem duties.
-USER root
-
-ENTRYPOINT ["node", "/opt/awf/enclave-mcp/server.js"]
diff --git a/containers/enclave/Dockerfile b/containers/enclave/Dockerfile
new file mode 100644
index 000000000..e9454f0cd
--- /dev/null
+++ b/containers/enclave/Dockerfile
@@ -0,0 +1,66 @@
+# AWF enclave images. Build context: containers/.
+
+FROM python:3.14.7-alpine3.24 AS enclave-script
+
+RUN python3 -c 'import json, pathlib, sys; sys.exit(0)' \
+ && test -x /usr/local/bin/python3 \
+ && rm -f /sbin/apk
+COPY enclave/script-entrypoint.py /usr/local/bin/run-enclave-script
+RUN chmod 0555 /usr/local/bin/run-enclave-script \
+ && mkdir -p /script /awf/seed
+
+FROM node:24.18.1-trixie-slim AS enclave-agent-build
+
+ARG COPILOT_CLI_VERSION=1.0.77
+ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
+RUN npm_config_ignore_scripts=false npm install -g "@github/copilot@${COPILOT_CLI_VERSION}" \
+ && npm_config_ignore_scripts=true npm install \
+ --prefix /usr/local/lib/node_modules/@github/copilot \
+ --omit=dev adm-zip@0.6.0 \
+ && find /usr/local/lib/node_modules/@github/copilot \
+ -path '*/foundry-local-sdk/node_modules/adm-zip' -prune -type d \
+ -exec sh -c 'rm -rf "$1" && cp -a /usr/local/lib/node_modules/@github/copilot/node_modules/adm-zip "$1"' sh {} \; \
+ && copilot --version | grep -q "GitHub Copilot CLI ${COPILOT_CLI_VERSION}"
+
+FROM ubuntu:26.04 AS enclave-agent
+
+ARG COPILOT_CLI_VERSION=1.0.77
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends bash ca-certificates git python3 ripgrep \
+ && test -r /etc/ssl/certs/ca-certificates.crt \
+ && rm -rf /var/lib/apt/lists/* \
+ && rm -f /usr/bin/apt /usr/bin/apt-get /usr/bin/apt-cache
+COPY --from=enclave-agent-build /usr/local/bin/node /usr/local/bin/node
+COPY --from=enclave-agent-build /usr/local/lib/node_modules/@github/copilot/ /usr/local/lib/node_modules/@github/copilot/
+RUN ln -s ../lib/node_modules/@github/copilot/npm-loader.js /usr/local/bin/copilot \
+ && node --version | grep -qE '^v24\.' \
+ && copilot --version | grep -q "GitHub Copilot CLI ${COPILOT_CLI_VERSION}" \
+ && rm -rf /root/.cache/copilot
+COPY enclave/agent-entrypoint.py /usr/local/bin/run-enclave-agent
+RUN chmod 0555 /usr/local/bin/run-enclave-agent \
+ && python3 -m py_compile /usr/local/bin/run-enclave-agent \
+ && rm -rf /usr/local/bin/__pycache__ \
+ && mkdir -p /agent /awf/seed
+
+FROM node:22.23.2-alpine3.24 AS enclave-mcp-server
+
+RUN apk add --no-cache docker-cli \
+ && test -x /usr/bin/docker \
+ && rm -rf /usr/local/lib/node_modules/npm \
+ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
+WORKDIR /opt/awf/enclave/mcp-server
+COPY bounded-execution/ /opt/awf/bounded-execution/
+COPY enclave/script-executor/ /opt/awf/enclave/script-executor/
+COPY enclave/agent-executor/ /opt/awf/enclave/agent-executor/
+COPY enclave/mcp-server/ /opt/awf/enclave/mcp-server/
+COPY enclave/seccomp.json /opt/awf/script-seccomp.json
+COPY enclave/seccomp.json /opt/awf/enclave-seccomp.json
+
+RUN chmod -R a-w /opt/awf \
+ && find /opt/awf -name '*.js' -type f -exec node --check {} \; \
+ && node -e "require('/opt/awf/enclave/mcp-server/agent-executor.js')"
+
+RUN mkdir -p /srv/awf/seeds /srv/awf/work \
+ /run/awf-enclave-mcp /run/awf-enclave-mcp-control /var/log/awf-enclave
+USER root
+ENTRYPOINT ["node", "/opt/awf/enclave/mcp-server/server.js"]
diff --git a/containers/bounded-agent/copilot-entrypoint.py b/containers/enclave/agent-entrypoint.py
similarity index 95%
rename from containers/bounded-agent/copilot-entrypoint.py
rename to containers/enclave/agent-entrypoint.py
index b113f05e2..e835465ce 100644
--- a/containers/bounded-agent/copilot-entrypoint.py
+++ b/containers/enclave/agent-entrypoint.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Run the pinned native Copilot CLI inside a bounded-agent enclave."""
+"""Run the pinned native Copilot CLI inside a enclave-agent enclave."""
import json
import os
@@ -14,8 +14,8 @@
SEED_DIR = Path("/awf/seed")
TASK_PATH = Path("/awf/task.txt")
SCHEMA_PATH = Path("/awf/schema.json")
-OUT_PATH = Path("/agent/out")
-SESSION_LOG_PATH = Path("/agent/session.jsonl")
+OUT_PATH = Path("/awf/out")
+SESSION_LOG_PATH = Path("/awf/session.jsonl")
AGENT_DIR = Path("/agent")
COPILOT_BIN = "/usr/local/bin/copilot"
@@ -106,7 +106,7 @@ def build_prompt(task: str, schema_text: str) -> str:
f"schema:\n{schema_text}\n"
)
return (
- "You are the native GitHub Copilot CLI running in an AWF bounded-agent enclave.\n"
+ "You are the native GitHub Copilot CLI running in an AWF enclave-agent enclave.\n"
"The repository root is your current directory and is mounted read-only at /awf/seed. "
"/agent and /tmp are bounded writable tmpfs storage. You may use your built-in shell, "
"bash, file-reading, and search tools. You have no GitHub MCP, no credentials, no host "
@@ -142,16 +142,16 @@ def append_engine_result(completed: subprocess.CompletedProcess) -> tuple[str, s
def main() -> int:
- if os.environ.get("AWF_BOUNDED_AGENT_ENGINE") != "copilot":
+ if os.environ.get("AWF_ENCLAVE_AGENT_ENGINE") != "copilot":
append_event({"event": "failure", "category": "configuration-invalid"})
return EXIT_CONFIGURATION_INVALID
try:
task = read_bounded(TASK_PATH)
schema_text = read_bounded(SCHEMA_PATH)
json.loads(schema_text)
- max_output = int(os.environ["AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES"])
- timeout = int(os.environ["AWF_BOUNDED_AGENT_DEADLINE_SECONDS"])
- model = os.environ["AWF_BOUNDED_AGENT_MODEL"]
+ max_output = int(os.environ["AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES"])
+ timeout = int(os.environ["AWF_ENCLAVE_AGENT_DEADLINE_SECONDS"])
+ model = os.environ["AWF_ENCLAVE_AGENT_MODEL"]
except (KeyError, OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
append_event({"event": "failure", "category": "input-invalid"})
return EXIT_INPUT_INVALID
diff --git a/containers/bounded-agent/broker/docker-client.js b/containers/enclave/agent-executor/docker-client.js
similarity index 94%
rename from containers/bounded-agent/broker/docker-client.js
rename to containers/enclave/agent-executor/docker-client.js
index 6df1e014b..47a491ba8 100644
--- a/containers/bounded-agent/broker/docker-client.js
+++ b/containers/enclave/agent-executor/docker-client.js
@@ -26,7 +26,7 @@ function runDocker(args, timeoutMs) {
exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
timedOut: Boolean(error && error.killed),
// Deliberately not surfaced: retained only as bounded strings so the
- // callback shape matches the bounded-query runner contract.
+ // callback shape matches the enclave-script runner contract.
stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '',
stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '',
});
diff --git a/containers/bounded-agent/broker/docker-enclave-runner.js b/containers/enclave/agent-executor/docker-enclave-runner.js
similarity index 79%
rename from containers/bounded-agent/broker/docker-enclave-runner.js
rename to containers/enclave/agent-executor/docker-enclave-runner.js
index a3b59c976..c7fcd3153 100644
--- a/containers/bounded-agent/broker/docker-enclave-runner.js
+++ b/containers/enclave/agent-executor/docker-enclave-runner.js
@@ -1,6 +1,9 @@
'use strict';
const defaultDockerClient = require('./docker-client');
+
+const EXPECTED_NETWORK_TOPOLOGY =
+ 'true|bridge|172.31.0.0/24,|awf-enclave-agent-api-proxy@172.31.0.30/24,';
const {
CLI_GRACE_MS,
buildRemoveArgs,
@@ -22,18 +25,29 @@ class DockerEnclaveRunner {
this.cleanupTail = Promise.resolve();
}
+ async assertNetworkIsolated() {
+ const network = await this.docker.runDocker([
+ 'network',
+ 'inspect',
+ '--format',
+ '{{.Internal}}|{{.Driver}}|{{range .IPAM.Config}}{{.Subnet}},{{end}}|' +
+ '{{range .Containers}}{{.Name}}@{{.IPv4Address}},{{end}}',
+ this.config.network,
+ ], 30_000);
+ if (network.exitCode !== 0 || network.stdout.trim() !== EXPECTED_NETWORK_TOPOLOGY) {
+ throw new Error(
+ 'The dedicated enclave-agent network is unavailable or not isolated; enclave agents ' +
+ 'never fall back to another network',
+ );
+ }
+ }
+
async assertAvailable() {
const image = await this.docker.runDocker(['image', 'inspect', this.config.enclaveImage], 60_000);
if (image.exitCode !== 0) {
throw new Error('Enclave image is not available locally');
}
- const network = await this.docker.runDocker(['network', 'inspect', this.config.network], 30_000);
- if (network.exitCode !== 0) {
- throw new Error(
- 'The dedicated bounded-agent network is not available; bounded agents never fall back to ' +
- 'another network',
- );
- }
+ await this.assertNetworkIsolated();
}
spec(runId, invocationId, seedId) {
@@ -49,11 +63,11 @@ class DockerEnclaveRunner {
async listContainerIds(args) {
const listed = await this.docker.runDocker(args, 30_000);
if (listed.exitCode !== 0) {
- throw new Error('Failed to reconcile bounded-agent containers');
+ throw new Error('Failed to reconcile enclave-agent containers');
}
const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean);
if (ids.some((id) => !/^[0-9a-f]{12,64}$/.test(id))) {
- throw new Error('Docker returned an invalid bounded-agent container id');
+ throw new Error('Docker returned an invalid enclave-agent container id');
}
return ids;
}
@@ -63,7 +77,7 @@ class DockerEnclaveRunner {
if (ids.length === 0) return;
const removed = await this.docker.runDocker(buildRemoveArgs(ids), 30_000);
if (removed.exitCode !== 0) {
- throw new Error('Failed to remove bounded-agent containers');
+ throw new Error('Failed to remove enclave-agent containers');
}
}
@@ -102,6 +116,7 @@ class DockerEnclaveRunner {
let result;
let runError;
try {
+ await this.assertNetworkIsolated();
result = await this.docker.runDocker(spec.launchArgs, timeoutMs);
} catch (error) {
runError = error;
diff --git a/containers/bounded-agent/broker/enclave-runner-spec.js b/containers/enclave/agent-executor/enclave-runner-spec.js
similarity index 72%
rename from containers/bounded-agent/broker/enclave-runner-spec.js
rename to containers/enclave/agent-executor/enclave-runner-spec.js
index 27c5837ec..ab2cf2cdf 100644
--- a/containers/bounded-agent/broker/enclave-runner-spec.js
+++ b/containers/enclave/agent-executor/enclave-runner-spec.js
@@ -10,8 +10,8 @@
*
* Isolation properties encoded here:
*
- * - `--network `: the enclave joins *only* the
- * dedicated `internal` bounded-agent network. Its sole reachable peer is
+ * - `--network `: the enclave joins *only* the
+ * dedicated `internal` enclave-agent network. Its sole reachable peer is
* the AWF API proxy; there is no `awf-net`, no `awf-ext`, no Squid, no
* general proxy, no primary agent, no broker, no safe-outputs collector, no
* MCP gateway, and no CLI proxy.
@@ -28,17 +28,7 @@ const CLI_GRACE_MS = 5_000;
/** Maximum file size the enclave may create, in bytes (per-file RLIMIT_FSIZE). */
const ENCLAVE_MAX_FILE_BYTES = 32 * 1024 * 1024;
-const RUN_LABEL = 'awf.bounded-agent.run';
-const INVOCATION_LABEL = 'awf.bounded-agent.invocation';
-
-/**
- * Unified-enclave labels.
- *
- * The unified enclave MCP server launches agent enclaves with these labels so
- * one AWF-side reconciliation pass (`awf.enclave.run=`) deterministically
- * removes every orphaned enclave container, script or agent, without knowing
- * which executor created it. Legacy bounded agents keep the labels above.
- */
+/** Labels shared by both enclave executors for unified orphan reconciliation. */
const ENCLAVE_RUN_LABEL = 'awf.enclave.run';
const ENCLAVE_INVOCATION_LABEL = 'awf.enclave.invocation';
const TRUSTED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -76,12 +66,10 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
throw new Error(`Unsupported OCI runtime in enclave runner: ${runtimeName}`);
}
- // Label keys and the container prefix are trusted broker configuration, not
- // request data. Omitting them preserves the legacy bounded-agent naming
- // byte-for-byte.
- const runLabelKey = config.runLabelKey || RUN_LABEL;
- const invocationLabelKey = config.invocationLabelKey || INVOCATION_LABEL;
- const containerPrefix = config.containerPrefix || 'awf-bounded-agent';
+ // Label keys and the container prefix are trusted server configuration.
+ const runLabelKey = config.runLabelKey || ENCLAVE_RUN_LABEL;
+ const invocationLabelKey = config.invocationLabelKey || ENCLAVE_INVOCATION_LABEL;
+ const containerPrefix = config.containerPrefix || 'awf-enclave-agent';
const containerName = `${containerPrefix}-${runId.slice(0, 12)}-${invocationId}`;
const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`;
const hostSeedDir = `${config.hostSeedsDir}/${seedId}`;
@@ -109,9 +97,9 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
'--tmpfs',
`${config.enclaveMountDir}:rw,nosuid,nodev,size=${config.tmpfsLimit},` +
`uid=${config.enclaveUid},gid=${config.enclaveGid},mode=0700`,
- '--hostname', config.enclaveHostname || 'bounded-agent',
+ '--hostname', config.enclaveHostname || 'enclave-agent',
'--workdir', config.enclaveSeedPath,
- '--env', `AWF_BOUNDED_AGENT_ENGINE=${config.engine}`,
+ '--env', `AWF_ENCLAVE_AGENT_ENGINE=${config.engine}`,
'--env', `HOME=${config.enclaveMountDir}/home`,
'--env', `COPILOT_HOME=${config.enclaveMountDir}/copilot`,
'--env', 'COPILOT_OFFLINE=true',
@@ -122,24 +110,22 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
'--env', `COPILOT_MODEL=${config.model}`,
'--env', 'PYTHONDONTWRITEBYTECODE=1',
'--env', 'PYTHONUNBUFFERED=1',
- '--env', `AWF_BOUNDED_AGENT_API_ENDPOINT=${config.apiEndpoint}`,
- '--env', `AWF_BOUNDED_AGENT_PROFILE=${config.profile}`,
- '--env', `AWF_BOUNDED_AGENT_MODEL=${config.model}`,
- '--env', `AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=${config.maxModelRequests}`,
- '--env', `AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=${config.maxModelTokens}`,
- '--env', `AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=${config.maxOutputBytes}`,
- '--env', `AWF_BOUNDED_AGENT_DEADLINE_SECONDS=${config.timeoutSeconds}`,
+ '--env', `AWF_ENCLAVE_AGENT_API_ENDPOINT=${config.apiEndpoint}`,
+ '--env', `AWF_ENCLAVE_AGENT_PROFILE=${config.profile}`,
+ '--env', `AWF_ENCLAVE_AGENT_MODEL=${config.model}`,
+ '--env', `AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES=${config.maxOutputBytes}`,
+ '--env', `AWF_ENCLAVE_AGENT_DEADLINE_SECONDS=${config.timeoutSeconds}`,
'-v', `${hostSeedDir}:${config.enclaveSeedPath}:ro`,
'-v', `${hostInvocationDir}/task.txt:${config.enclaveTaskPath}:ro`,
'-v', `${hostInvocationDir}/schema.json:${config.enclaveSchemaPath}:ro`,
- '-v', `${hostInvocationDir}/out:${config.enclaveMountDir}/out:rw`,
- '-v', `${hostInvocationDir}/session.jsonl:${config.enclaveMountDir}/session.jsonl:rw`,
+ '-v', `${hostInvocationDir}/out:/awf/out:rw`,
+ '-v', `${hostInvocationDir}/session.jsonl:/awf/session.jsonl:rw`,
];
if (runtimeName !== undefined) {
launchArgs.push('--runtime', runtimeName);
}
- launchArgs.push('--entrypoint', '/usr/local/bin/run-bounded-agent', config.enclaveImage);
+ launchArgs.push('--entrypoint', '/usr/local/bin/run-enclave-agent', config.enclaveImage);
return Object.freeze({
containerName,
@@ -154,11 +140,6 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
});
}
-/** Compatibility helper for focused argument tests. */
-function buildEnclaveArgs(params) {
- return deriveEnclaveContainerSpec(params).launchArgs;
-}
-
function buildRemoveArgs(containerIds) {
return freezeArray(['rm', '-f', ...containerIds]);
}
@@ -168,9 +149,6 @@ module.exports = {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
- INVOCATION_LABEL,
- RUN_LABEL,
- buildEnclaveArgs,
buildRemoveArgs,
deriveEnclaveContainerSpec,
normalizeTimeoutMs,
diff --git a/containers/bounded-agent/broker/enclave-runner.js b/containers/enclave/agent-executor/enclave-runner.js
similarity index 88%
rename from containers/bounded-agent/broker/enclave-runner.js
rename to containers/enclave/agent-executor/enclave-runner.js
index 92325aa73..4764e1634 100644
--- a/containers/bounded-agent/broker/enclave-runner.js
+++ b/containers/enclave/agent-executor/enclave-runner.js
@@ -7,13 +7,12 @@ const {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
- buildEnclaveArgs,
deriveEnclaveContainerSpec,
normalizeTimeoutMs,
} = require('./enclave-runner-spec');
/**
- * Trusted broker interface for one-enclave-per-invocation execution.
+ * Trusted server interface for one-enclave-per-invocation execution.
*
* @typedef {object} EnclaveRunner
* @property {() => Promise} assertAvailable
@@ -27,7 +26,7 @@ const {
*/
/**
- * Selects a runner only from AWF's normalized broker configuration.
+ * Selects a runner only from AWF's normalized server configuration.
*
* Unknown values fail closed. In particular, gVisor never falls back to the
* daemon's default OCI runtime when runsc is unavailable, and the `sbx`
@@ -49,14 +48,13 @@ function createEnclaveRunner(config, deps = {}) {
if (config.backend === 'sbx') {
return new SbxEnclaveRunner(config, deps);
}
- throw new Error(`Unsupported bounded-agent backend: ${config.backend}`);
+ throw new Error(`Unsupported enclave-agent backend: ${config.backend}`);
}
module.exports = {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
- buildEnclaveArgs,
createEnclaveRunner,
deriveEnclaveContainerSpec,
normalizeTimeoutMs,
diff --git a/containers/enclave/agent-executor/framing.js b/containers/enclave/agent-executor/framing.js
new file mode 100644
index 000000000..938b543f1
--- /dev/null
+++ b/containers/enclave/agent-executor/framing.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const {
+ MAX_PRIVATE_REPO_LENGTH,
+ PRIVATE_REPOSITORY_PATTERN,
+ validateSchema,
+} = require('../../bounded-execution/finite-disclosure');
+const MAX_TASK_BYTES = 64 * 1024;
+
+/**
+ * Request validation for the MCP server's agent executor.
+ *
+ * The accepted surface is deliberately tiny. Any other `x-awf-*` header, any
+ * duplicate header, and any unknown/forbidden request key is rejected — a
+ * request can never express an image, command, executable, mount, environment,
+ * endpoint, network, proxy, credential, timeout, resource limit, runtime, or
+ * tool definition.
+ */
+
+const PAYLOAD_KEY = 'prompt';
+
+/**
+ * Controls a request may never express.
+ *
+ * Redundant with the unknown-key rule below by construction; kept explicit so
+ * an accidental future widening of the accepted key set fails a test instead of
+ * silently granting a capability.
+ */
+const BASE_FORBIDDEN_REQUEST_KEYS = [
+ 'image', 'images', 'command', 'cmd', 'args', 'argv', 'entrypoint', 'executable',
+ 'interpreter', 'script', 'shell', 'mount', 'mounts', 'volume', 'volumes', 'bind',
+ 'path', 'paths', 'workdir', 'env', 'environment', 'endpoint', 'endpoints', 'baseUrl',
+ 'url', 'host', 'network', 'networks', 'dns', 'proxy', 'httpProxy', 'httpsProxy',
+ 'credential', 'credentials', 'apiKey', 'token', 'authorization', 'headers',
+ 'timeout', 'timeoutSeconds', 'deadline', 'memory', 'memoryLimit', 'cpu', 'cpuLimit',
+ 'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'engine', 'sandbox',
+ 'profile', 'model', 'provider', 'temperature', 'maxTokens',
+ 'tool', 'tools', 'toolChoice', 'functions', 'systemPrompt', 'system', 'messages',
+];
+
+function isPlainObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Validates an assembled enclave-agent request against the fixed protocol.
+ *
+ * @returns `{ valid: true, request }` or `{ valid: false, errors }`. Errors are
+ * only ever written to the protected audit log, never returned to the caller.
+ */
+function validateEnclaveAgentRequest(raw, options = {}) {
+ const errors = [];
+ if (!isPlainObject(raw)) {
+ return { valid: false, errors: ['request must be a JSON object'] };
+ }
+
+ const allowedKeys = ['privateRepo', 'schema', PAYLOAD_KEY];
+ const forbidden = BASE_FORBIDDEN_REQUEST_KEYS.concat(['task']).filter(
+ (key) => Object.prototype.hasOwnProperty.call(raw, key),
+ );
+ for (const key of forbidden) {
+ errors.push(`request may not specify "${key}"`);
+ }
+ for (const key of Object.keys(raw)) {
+ if (!allowedKeys.includes(key) && !forbidden.includes(key)) {
+ errors.push(`unknown request key: "${key}"`);
+ }
+ }
+
+ const { privateRepo, schema } = raw;
+ const prompt = raw[PAYLOAD_KEY];
+
+ if (typeof privateRepo !== 'string') {
+ errors.push('privateRepo must be a string');
+ } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH) {
+ errors.push('privateRepo exceeds the maximum length');
+ } else if (!PRIVATE_REPOSITORY_PATTERN.test(privateRepo)) {
+ errors.push('privateRepo must be a bare owner/repo slug');
+ }
+
+ const schemaValidation = validateSchema(schema);
+ if (!schemaValidation.valid) {
+ errors.push(...schemaValidation.errors);
+ }
+
+ const configuredLimit = Number.isInteger(options.maxTaskBytes) && options.maxTaskBytes > 0
+ ? options.maxTaskBytes
+ : MAX_TASK_BYTES;
+ const taskLimit = Math.min(configuredLimit, MAX_TASK_BYTES);
+ if (typeof prompt !== 'string') {
+ errors.push(`${PAYLOAD_KEY} must be a string`);
+ } else if (prompt.length === 0) {
+ errors.push(`${PAYLOAD_KEY} must not be empty`);
+ } else if (Buffer.byteLength(prompt, 'utf8') > taskLimit) {
+ errors.push(`${PAYLOAD_KEY} exceeds the maximum size`);
+ }
+
+ if (errors.length > 0) return { valid: false, errors };
+
+ return {
+ valid: true,
+ request: { privateRepo, schema: schemaValidation.schema, prompt },
+ };
+}
+
+module.exports = {
+ MAX_TASK_BYTES,
+ validateEnclaveAgentRequest,
+};
diff --git a/containers/bounded-agent/broker/gvisor-enclave-runner.js b/containers/enclave/agent-executor/gvisor-enclave-runner.js
similarity index 100%
rename from containers/bounded-agent/broker/gvisor-enclave-runner.js
rename to containers/enclave/agent-executor/gvisor-enclave-runner.js
diff --git a/containers/bounded-agent/broker/sbx-capability-probe.js b/containers/enclave/agent-executor/sbx-capability-probe.js
similarity index 93%
rename from containers/bounded-agent/broker/sbx-capability-probe.js
rename to containers/enclave/agent-executor/sbx-capability-probe.js
index f1aaf1cd4..19a20d121 100644
--- a/containers/bounded-agent/broker/sbx-capability-probe.js
+++ b/containers/enclave/agent-executor/sbx-capability-probe.js
@@ -17,9 +17,9 @@ const REQUIRED_EXEC_FLAGS = Object.freeze([
/**
* Capabilities that sbx must expose before AWF can safely launch a
- * bounded-agent enclave VM.
+ * enclave-agent enclave VM.
*
- * Unlike bounded queries (which run with `--network=none`), a bounded-agent
+ * Unlike enclave scripts (which run with `--network=none`), a enclave-agent
* enclave must reach the AWF API proxy and nothing else — so instead of a
* no-network primitive, sbx needs a *named-network attach with mandatory
* lateral-peer denial*: the VM must be able to join a single named network
@@ -44,8 +44,8 @@ const LATERAL_PEER_DENIAL_PRIMITIVE =
'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' +
'(hard network-policy / capability-token ingress primitive)';
-/** AWF has not published a pinned, immutable bounded-agent sbx template/bootstrap. */
-const PINNED_TEMPLATE_MISSING = 'pinned AWF bounded-agent sbx template and bootstrap';
+/** AWF has not published a pinned, immutable enclave-agent sbx template/bootstrap. */
+const PINNED_TEMPLATE_MISSING = 'pinned AWF enclave-agent sbx template and bootstrap';
function includesFlag(help, flag) {
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -59,7 +59,7 @@ async function inspectHelp(sbx, command) {
/**
* Probes the installed sbx CLI for the exact version, authentication, and
- * hard-isolation flags a bounded-agent enclave requires.
+ * hard-isolation flags a enclave-agent enclave requires.
*
* This never reports `supported: true` on flag detection alone: even when
* every enumerated flag is present, the two capability primitives AWF cannot
diff --git a/containers/bounded-agent/broker/sbx-client.js b/containers/enclave/agent-executor/sbx-client.js
similarity index 100%
rename from containers/bounded-agent/broker/sbx-client.js
rename to containers/enclave/agent-executor/sbx-client.js
diff --git a/containers/bounded-agent/broker/sbx-enclave-runner-spec.js b/containers/enclave/agent-executor/sbx-enclave-runner-spec.js
similarity index 87%
rename from containers/bounded-agent/broker/sbx-enclave-runner-spec.js
rename to containers/enclave/agent-executor/sbx-enclave-runner-spec.js
index 2e8a3a109..fa72e6cd2 100644
--- a/containers/bounded-agent/broker/sbx-enclave-runner-spec.js
+++ b/containers/enclave/agent-executor/sbx-enclave-runner-spec.js
@@ -12,13 +12,13 @@ const SBX_CLI_GRACE_MS = 15_000;
* Pinned placeholder template/bootstrap reference.
*
* This is intentionally not a real, resolvable template: AWF has not
- * published a bounded-agent sbx template because current sbx cannot enforce
+ * published a enclave-agent sbx template because current sbx cannot enforce
* the mandatory isolation controls a real template would depend on (see
* `./sbx-capability-probe.js`). The value documents the exact shape a future
* pinned reference must take (a content-addressed tag), and is never used to
* launch a real enclave while the capability probe reports it missing.
*/
-const SBX_ENCLAVE_TEMPLATE = 'awf/bounded-agent-sandbox-templates:sbx-enclave@sha256:unsupported-until-pinned';
+const SBX_ENCLAVE_TEMPLATE = 'awf/enclave-agent-sandbox-templates:sbx-enclave@sha256:unsupported-until-pinned';
const TRUSTED_RUN_ID_PATTERN = /^[0-9a-f]{32}$/;
const TRUSTED_INVOCATION_ID_PATTERN = /^[0-9a-f]{24}$/;
@@ -35,7 +35,7 @@ function freeze(values) {
}
/**
- * Derives the entire sbx CLI surface for one bounded-agent enclave invocation
+ * Derives the entire sbx CLI surface for one enclave-agent enclave invocation
* from trusted broker state.
*
* This specification is intentionally not launchable while the capability
@@ -51,7 +51,7 @@ function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) {
assertTrustedId('invocationId', invocationId, TRUSTED_INVOCATION_ID_PATTERN);
assertTrustedId('seedId', seedId, TRUSTED_SEED_ID_PATTERN);
- const runPrefix = `awf-bounded-agent-sbx-${runId}-`;
+ const runPrefix = `awf-enclave-agent-sbx-${runId}-`;
const sandboxName = `${runPrefix}${invocationId}`;
const hostInvocationDir = `${config.sbxWorkDir}/${invocationId}`;
const hostSeedDir = `${config.sbxSeedsDir}/${seedId}`;
@@ -69,7 +69,7 @@ function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) {
'--cpus', String(config.cpuLimit),
'--memory', config.memoryLimit,
'--template', SBX_ENCLAVE_TEMPLATE,
- // Distinct from bounded queries' `--network=none`: a bounded-agent
+ // Distinct from enclave scripts' `--network=none`: a enclave-agent
// enclave must reach the API proxy and *only* the API proxy. sbx has
// no verified lateral-peer-denial primitive today (see
// REQUIRED_HARD_ISOLATION_FLAGS), so this argument is never issued
@@ -81,8 +81,8 @@ function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) {
'--mount-target', `${hostSeedDir}:${config.enclaveSeedPath}:ro`,
'--mount-target', `${taskPath}:${config.enclaveTaskPath}:ro`,
'--mount-target', `${schemaPath}:${config.enclaveSchemaPath}:ro`,
- '--mount-target', `${outPath}:${config.enclaveMountDir}/out:rw`,
- '--mount-target', `${hostInvocationDir}/session.jsonl:${config.enclaveMountDir}/session.jsonl:rw`,
+ '--mount-target', `${outPath}:/awf/out:rw`,
+ '--mount-target', `${hostInvocationDir}/session.jsonl:/awf/session.jsonl:rw`,
'shell',
workspaceDir,
]),
@@ -91,7 +91,7 @@ function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) {
'--user', `${config.enclaveUid}:${config.enclaveGid}`,
'--workdir', config.enclaveMountDir,
sandboxName,
- '/usr/local/bin/run-bounded-agent',
+ '/usr/local/bin/run-enclave-agent',
]),
stopArgs: freeze(['stop', sandboxName]),
removeArgs: freeze(['rm', '--force', sandboxName]),
diff --git a/containers/bounded-agent/broker/sbx-enclave-runner.js b/containers/enclave/agent-executor/sbx-enclave-runner.js
similarity index 95%
rename from containers/bounded-agent/broker/sbx-enclave-runner.js
rename to containers/enclave/agent-executor/sbx-enclave-runner.js
index f3f91d7ec..1d4fa12ba 100644
--- a/containers/bounded-agent/broker/sbx-enclave-runner.js
+++ b/containers/enclave/agent-executor/sbx-enclave-runner.js
@@ -57,7 +57,7 @@ class SbxEnclaveRunner {
const report = await this.probe(this.sbx);
if (!report.supported) {
throw new Error(
- 'sbx bounded-agent enclave backend is blocked: the installed sbx runtime cannot enforce all ' +
+ 'sbx enclave-agent enclave backend is blocked: the installed sbx runtime cannot enforce all ' +
`mandatory isolation controls (${report.missing.join(', ')}). No fallback is permitted.`,
);
}
@@ -75,7 +75,7 @@ class SbxEnclaveRunner {
async listRunSandboxes(runId) {
const spec = this.spec(runId, '0'.repeat(24), '0'.repeat(32));
const listed = await this.sbx.runSbx(spec.listArgs, 30_000);
- if (listed.exitCode !== 0) throw new Error('Failed to reconcile bounded-agent sbx VMs');
+ if (listed.exitCode !== 0) throw new Error('Failed to reconcile enclave-agent sbx VMs');
return parseSandboxNames(listed.stdout).filter((name) => name.startsWith(spec.runPrefix));
}
@@ -84,11 +84,11 @@ class SbxEnclaveRunner {
if (stopped.exitCode !== 0) {
const inventory = await this.sbx.runSbx(['ls', '--quiet'], 30_000);
if (inventory.exitCode !== 0 || inventory.stdout.split('\n').includes(name)) {
- throw new Error('Failed to stop bounded-agent sbx VM');
+ throw new Error('Failed to stop enclave-agent sbx VM');
}
}
const removed = await this.sbx.runSbx(['rm', '--force', name], 30_000);
- if (removed.exitCode !== 0) throw new Error('Failed to remove bounded-agent sbx VM');
+ if (removed.exitCode !== 0) throw new Error('Failed to remove enclave-agent sbx VM');
}
/** Deterministic orphan cleanup for every VM name-prefixed with this run. */
@@ -129,7 +129,7 @@ class SbxEnclaveRunner {
if (created.timedOut) {
result = created;
} else if (created.exitCode !== 0) {
- throw new Error('Failed to create bounded-agent sbx VM');
+ throw new Error('Failed to create enclave-agent sbx VM');
} else if (this.nowMs() >= deadlineMs) {
result = { exitCode: 124, timedOut: true, stdout: '', stderr: '' };
} else {
diff --git a/containers/bounded-agent/broker/workspace.js b/containers/enclave/agent-executor/workspace.js
similarity index 98%
rename from containers/bounded-agent/broker/workspace.js
rename to containers/enclave/agent-executor/workspace.js
index 31f7c0041..2bd07020b 100644
--- a/containers/bounded-agent/broker/workspace.js
+++ b/containers/enclave/agent-executor/workspace.js
@@ -6,7 +6,7 @@ const path = require('path');
/**
* Per-invocation private workspace management.
*
- * A bounded agent never writes to the repository: the immutable seed is
+ * A enclave agent never writes to the repository: the immutable seed is
* bind-mounted read-only straight into the enclave, so there is no writable
* copy of private source anywhere on the host. The workspace therefore holds
* only three small, broker-owned files:
diff --git a/containers/bounded-query/enclave-mcp/agent-executor.js b/containers/enclave/mcp-server/agent-executor.js
similarity index 76%
rename from containers/bounded-query/enclave-mcp/agent-executor.js
rename to containers/enclave/mcp-server/agent-executor.js
index b71cce3c7..425960dcd 100644
--- a/containers/bounded-query/enclave-mcp/agent-executor.js
+++ b/containers/enclave/mcp-server/agent-executor.js
@@ -1,12 +1,12 @@
'use strict';
-const { createEnclaveRunner } = require('../agent-broker/enclave-runner');
-const agentWorkspace = require('../agent-broker/workspace');
-const { validateBoundedAgentRequest } = require('../agent-broker/framing');
+const { createEnclaveRunner } = require('../agent-executor/enclave-runner');
+const agentWorkspace = require('../agent-executor/workspace');
+const { validateEnclaveAgentRequest } = require('../agent-executor/framing');
/**
* Adapters that let the unified enclave MCP server drive the audited
- * bounded-agent enclave through the shared broker execution pipeline.
+ * enclave-agent enclave through the shared executor pipeline.
*
* Nothing here re-implements isolation. The runner, the container
* specification (single-use enclave, immutable seed mounted `ro`, `--read-only`
@@ -14,8 +14,8 @@ const { validateBoundedAgentRequest } = require('../agent-broker/framing');
* `no-new-privileges`, seccomp, memory/CPU/PID/file-size/timeout bounds, the
* dedicated API-proxy-only network), the native entrypoint, the bounded result
* file contract, the runtime availability proofs, the run/invocation labels,
- * and the orphan reconciliation all come from the audited bounded-agent
- * modules verbatim. This file only maps the shared broker's script-shaped
+ * and the orphan reconciliation all come from the audited enclave-agent
+ * modules verbatim. This file only maps the shared handler's script-shaped
* calls onto them and fixes the caller-facing payload name to `prompt`.
*/
@@ -38,26 +38,23 @@ const AGENT_PAYLOAD_KEY = 'prompt';
/**
* Validates one `enclave_run_agent` request against the fixed agent grammar.
*
- * Delegates to the audited bounded-agent validator with the caller-facing
+ * Delegates to the audited enclave-agent validator with the caller-facing
* payload name, so every forbidden control (image, command, mounts, env,
* endpoints, network, credentials, resources, runtime, profile, model,
* provider, tools, system prompt, messages, and the alternate payload
* spelling) is rejected by exactly one implementation.
*/
function createAgentRequestValidator(maxPromptBytes) {
- return (request) => validateBoundedAgentRequest(request, {
- maxTaskBytes: maxPromptBytes,
- payloadKey: AGENT_PAYLOAD_KEY,
- });
+ return (request) => validateEnclaveAgentRequest(request, { maxTaskBytes: maxPromptBytes });
}
/**
* Workspace adapter.
*
- * The shared broker speaks `createInvocationWorkspace`/`readQueryOutput`/
- * `destroyInvocationWorkspace`; the bounded-agent workspace speaks the same
+ * The shared handler speaks `createInvocationWorkspace`/`readQueryOutput`/
+ * `destroyInvocationWorkspace`; the enclave-agent workspace speaks the same
* operations with an enclave-specific result reader and a protected session
- * transcript. `preserveInvocationArtifacts` is the broker's optional hook,
+ * transcript. `preserveInvocationArtifacts` is the handler's optional hook,
* invoked inside the charged timing bucket and before teardown.
*/
const agentWorkspaceAdapter = {
@@ -88,7 +85,7 @@ const agentWorkspaceAdapter = {
};
/**
- * Runner adapter around the audited bounded-agent EnclaveRunner.
+ * Runner adapter around the audited enclave-agent EnclaveRunner.
*
* The backend is selected only from normalized trusted configuration; unknown
* values fail closed and gVisor never downgrades to the daemon's default OCI
@@ -99,7 +96,7 @@ function createAgentRunner(config, deps = {}) {
return {
assertAvailable: () => runner.assertAvailable(),
reconcileRun: (runId) => runner.reconcileRun(runId),
- runQueryContainer: ({ runId, invocationId, seedId, timeoutMs }) => runner.runEnclaveContainer({
+ runScriptContainer: ({ runId, invocationId, seedId, timeoutMs }) => runner.runEnclaveContainer({
config,
runId,
invocationId,
diff --git a/containers/bounded-query/enclave-mcp/config.js b/containers/enclave/mcp-server/config.js
similarity index 89%
rename from containers/bounded-query/enclave-mcp/config.js
rename to containers/enclave/mcp-server/config.js
index 16232b968..62e1fed3a 100644
--- a/containers/bounded-query/enclave-mcp/config.js
+++ b/containers/enclave/mcp-server/config.js
@@ -3,17 +3,17 @@
const fs = require('fs');
const path = require('path');
const {
- MAX_QUERY_TIMEOUT_SECONDS,
+ MAX_ENCLAVE_TIMEOUT_SECONDS,
MAX_RESULT_BYTES,
MAX_SCRIPT_BYTES,
-} = require('../bounded-execution/finite-disclosure');
-const { ENCLAVE_SENSITIVITY_RUN_BITS } = require('../bounded-execution/sensitivity-policy');
-const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging');
+} = require('../../bounded-execution/finite-disclosure');
+const { ENCLAVE_SENSITIVITY_RUN_BITS } = require('../../bounded-execution/sensitivity-policy');
+const { parsePrivateRepositorySeedMap } = require('../../bounded-execution/repository-staging');
const {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_RUN_LABEL,
-} = require('../broker/query-runner-spec');
-const { MAX_TASK_BYTES } = require('../agent-broker/framing');
+} = require('../script-executor/script-runner-spec');
+const { MAX_TASK_BYTES } = require('../agent-executor/framing');
const SEEDS_DIR = '/srv/awf/seeds';
const WORK_DIR = '/srv/awf/work';
@@ -69,8 +69,8 @@ function dockerSize(name, fallback) {
}
function loadConfig(files = fs) {
- const queryBackend = requireEnv('AWF_ENCLAVE_BACKEND');
- if (queryBackend !== 'docker' && queryBackend !== 'gvisor') {
+ const executorBackend = requireEnv('AWF_ENCLAVE_BACKEND');
+ if (executorBackend !== 'docker' && executorBackend !== 'gvisor') {
throw new Error('AWF_ENCLAVE_BACKEND must be docker or gvisor');
}
const primaryBackend = requireEnv('AWF_ENCLAVE_PRIMARY_BACKEND');
@@ -84,7 +84,7 @@ function loadConfig(files = fs) {
const timeoutSeconds = positiveInt(
'AWF_ENCLAVE_TIMEOUT',
30,
- MAX_QUERY_TIMEOUT_SECONDS,
+ MAX_ENCLAVE_TIMEOUT_SECONDS,
);
const capability = files.readFileSync(CAPABILITY_PATH, 'utf8').trim();
if (!/^[0-9a-f]{64}$/.test(capability)) {
@@ -101,13 +101,13 @@ function loadConfig(files = fs) {
controlDir: CONTROL_DIR,
readyPath: READY_PATH,
auditDir: AUDIT_DIR,
- querySeccompPath: '/opt/awf/query-seccomp.json',
+ querySeccompPath: '/opt/awf/script-seccomp.json',
queryMountDir: '/query',
queryScriptPath: '/awf/query-script.py',
queryUid: 65534,
queryGid: 65534,
queryImage: requireEnv('AWF_ENCLAVE_IMAGE'),
- queryBackend,
+ executorBackend,
primaryBackend,
timeoutSeconds,
maxInvocations: positiveInt('AWF_ENCLAVE_MAX_INVOCATIONS', 32),
@@ -129,7 +129,7 @@ function isScriptExecutorEnabled() {
return process.env.AWF_ENCLAVE_SCRIPT_ENABLED === 'true';
}
-/** True when this run exposes the bounded-agent executor. */
+/** True when this run exposes the enclave-agent executor. */
function isAgentExecutorEnabled() {
return process.env.AWF_ENCLAVE_AGENT_ENABLED === 'true';
}
@@ -162,7 +162,7 @@ function loadServerConfig(files = fs) {
}
/**
- * Loads the trusted bounded-agent executor configuration.
+ * Loads the trusted enclave-agent executor configuration.
*
* Every value here is AWF configuration delivered through the server's own
* environment: image, runtime backend, engine, profile, model, API-proxy
@@ -211,16 +211,14 @@ function loadAgentConfig(server) {
enclaveHostname: 'enclave-agent',
enclaveImage: requireEnv('AWF_ENCLAVE_AGENT_IMAGE'),
backend,
- // Mirrored under the shared broker's telemetry field name so both
- // executors emit one narrow, content-free runtime shape.
- queryBackend: backend,
+ executorBackend: backend,
primaryBackend: server.primaryBackend,
engine,
profile,
model: requireEnv('AWF_ENCLAVE_AGENT_MODEL'),
apiEndpoint,
network,
- timeoutSeconds: positiveInt('AWF_ENCLAVE_AGENT_TIMEOUT', 120, MAX_QUERY_TIMEOUT_SECONDS),
+ timeoutSeconds: positiveInt('AWF_ENCLAVE_AGENT_TIMEOUT', 120, MAX_ENCLAVE_TIMEOUT_SECONDS),
memoryLimit: dockerSize('AWF_ENCLAVE_AGENT_MEMORY', '512m'),
cpuLimit,
pidsLimit: positiveInt('AWF_ENCLAVE_AGENT_PIDS', 128),
@@ -228,8 +226,6 @@ function loadAgentConfig(server) {
maxOutputBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES),
maxPromptBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES', 4096, MAX_TASK_BYTES),
maxInvocations: positiveInt('AWF_ENCLAVE_AGENT_MAX_INVOCATIONS', 8),
- maxModelRequests: positiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS', 8, 64),
- maxModelTokens: positiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS', 1024, 32768),
runLabelKey: ENCLAVE_RUN_LABEL,
invocationLabelKey: ENCLAVE_INVOCATION_LABEL,
containerPrefix: AGENT_CONTAINER_PREFIX,
diff --git a/containers/bounded-query/enclave-mcp/healthcheck.js b/containers/enclave/mcp-server/healthcheck.js
similarity index 100%
rename from containers/bounded-query/enclave-mcp/healthcheck.js
rename to containers/enclave/mcp-server/healthcheck.js
diff --git a/containers/bounded-query/enclave-mcp/mcp-protocol.js b/containers/enclave/mcp-server/mcp-protocol.js
similarity index 89%
rename from containers/bounded-query/enclave-mcp/mcp-protocol.js
rename to containers/enclave/mcp-server/mcp-protocol.js
index ec387c659..82a402d80 100644
--- a/containers/bounded-query/enclave-mcp/mcp-protocol.js
+++ b/containers/enclave/mcp-server/mcp-protocol.js
@@ -4,7 +4,7 @@ const {
MAX_SCRIPT_BYTES,
MAX_SCHEMA_BYTES,
strictParseJson,
-} = require('../bounded-execution/finite-disclosure');
+} = require('../../bounded-execution/finite-disclosure');
const MCP_PROTOCOL_VERSION = '2025-06-18';
const TOOL_NAME = 'enclave_run_script';
@@ -99,15 +99,10 @@ const TOOL_PAYLOAD_KEYS = Object.freeze({
const TOOLS_LIST_RESULT = Object.freeze({ tools: Object.freeze([TOOL]) });
/**
- * Resolves the brokers this server exposes.
- *
- * `deps.brokers` is the unified form: a map from tool name to the trusted
- * broker for that executor. `deps.broker` remains supported as the
- * script-executor-only shorthand.
+ * Resolves the executor handlers this server exposes.
*/
-function resolveBrokers(deps) {
- if (deps.brokers) return deps.brokers;
- return deps.broker ? { [TOOL_NAME]: deps.broker } : {};
+function resolveHandlers(deps) {
+ return deps.handlers || {};
}
/**
@@ -118,9 +113,9 @@ function resolveBrokers(deps) {
* per tool.
*/
function toolsListResult(deps) {
- const brokers = resolveBrokers(deps);
+ const handlers = resolveHandlers(deps);
const tools = Object.keys(TOOLS_BY_NAME)
- .filter((name) => brokers[name] !== undefined)
+ .filter((name) => handlers[name] !== undefined)
.map((name) => TOOLS_BY_NAME[name]);
return { tools };
}
@@ -147,9 +142,9 @@ function hasOnlyKeys(value, allowed) {
);
}
-function brokerCall(broker, request) {
+function handlerCall(handler, request) {
return new Promise((resolve) => {
- broker.handle(request, (canonicalJson) => {
+ handler.handle(request, (canonicalJson) => {
const parsed = strictParseJson(canonicalJson);
if (!parsed || !parsed.value || parsed.value.status !== 'ok') {
resolve(canonicalToolError());
@@ -197,10 +192,10 @@ async function dispatchJsonRpc(message, deps) {
}
if (message.method === 'tools/call') {
- const brokers = resolveBrokers(deps);
+ const handlers = resolveHandlers(deps);
if (!hasOnlyKeys(message.params, new Set(['name', 'arguments']))
|| typeof message.params.name !== 'string'
- || !Object.prototype.hasOwnProperty.call(brokers, message.params.name)
+ || !Object.prototype.hasOwnProperty.call(handlers, message.params.name)
|| !Object.prototype.hasOwnProperty.call(message.params, 'arguments')) {
return rpcError(message.id, -32602, 'Invalid params');
}
@@ -211,8 +206,8 @@ async function dispatchJsonRpc(message, deps) {
}
const payloadKey = TOOL_PAYLOAD_KEYS[name];
const limit = payloadLimitFor(name, deps);
- // An oversized payload is dropped here so the broker never buffers it; the
- // caller still observes only the canonical error the broker emits.
+ // An oversized payload is dropped before the handler buffers it; the
+ // caller still observes only its canonical error.
const tooLarge = (
args
&& typeof args[payloadKey] === 'string'
@@ -228,7 +223,7 @@ async function dispatchJsonRpc(message, deps) {
}
}
try {
- return rpcResult(message.id, await brokerCall(brokers[name], request));
+ return rpcResult(message.id, await handlerCall(handlers[name], request));
} finally {
if (release) release();
}
diff --git a/containers/bounded-query/enclave-mcp/server.js b/containers/enclave/mcp-server/server.js
similarity index 88%
rename from containers/bounded-query/enclave-mcp/server.js
rename to containers/enclave/mcp-server/server.js
index 1d9cb8850..82591e9fb 100644
--- a/containers/bounded-query/enclave-mcp/server.js
+++ b/containers/enclave/mcp-server/server.js
@@ -3,11 +3,11 @@
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
-const { createProtectedAuditLog } = require('../bounded-execution/protected-audit');
-const { createEnclaveInformationBudgetLedger } = require('../bounded-execution/sensitivity-ledger');
-const { createBroker } = require('../broker/broker');
-const { createQueryRunner } = require('../broker/query-runner');
-const { createRuntimeTelemetry } = require('../broker/runtime-telemetry');
+const { createProtectedAuditLog } = require('../../bounded-execution/protected-audit');
+const { createEnclaveInformationBudgetLedger } = require('../../bounded-execution/sensitivity-ledger');
+const { createExecutorHandler } = require('../script-executor/executor-handler');
+const { createScriptRunner } = require('../script-executor/script-runner');
+const { createRuntimeTelemetry } = require('../script-executor/runtime-telemetry');
const {
isAgentExecutorEnabled,
isScriptExecutorEnabled,
@@ -170,7 +170,7 @@ async function main() {
// One serialization lane for the whole run: at most one enclave — script or
// agent — holds private repository content at a time.
const lane = { tail: Promise.resolve() };
- const brokers = {};
+ const handlers = {};
const runners = [];
const executors = [];
let maxScriptBytes;
@@ -178,12 +178,12 @@ async function main() {
if (scriptEnabled) {
const config = loadConfig();
- const runner = createQueryRunner(config);
+ const runner = createScriptRunner(config);
await runner.assertAvailable();
await runner.reconcileRun(runId);
runners.push({ runner, config });
maxScriptBytes = config.maxScriptBytes;
- brokers[TOOL_NAME] = createBroker({
+ handlers[TOOL_NAME] = createExecutorHandler({
config,
seedMap: seeds,
runId,
@@ -205,7 +205,7 @@ async function main() {
await runner.reconcileRun(runId);
runners.push({ runner, config });
maxPromptBytes = config.maxPromptBytes;
- brokers[AGENT_TOOL_NAME] = createBroker({
+ handlers[AGENT_TOOL_NAME] = createExecutorHandler({
config,
seedMap: seeds,
runId,
@@ -224,17 +224,20 @@ async function main() {
executors.push('agent');
}
- const backends = runners[0].config;
+ const executorBackends = new Set(runners.map(({ config }) => config.executorBackend));
+ const startupExecutorBackend = executorBackends.size === 1
+ ? runners[0].config.executorBackend
+ : 'mixed';
telemetry.emit({
primaryBackend: serverConfig.primaryBackend,
- queryBackend: backends.queryBackend,
+ executorBackend: startupExecutorBackend,
lifecycleClass: 'startup',
capabilityState: 'supported',
category: 'ready',
});
const server = createMcpServer({
- brokers,
+ handlers,
capability: serverConfig.capability,
maxScriptBytes,
maxPromptBytes,
@@ -248,14 +251,14 @@ async function main() {
const shutdown = async () => {
if (stopping) return;
stopping = true;
- for (const broker of Object.values(brokers)) broker.close();
+ for (const handler of Object.values(handlers)) handler.close();
server.close();
try {
await lane.tail;
for (const { runner } of runners) await runner.reconcileRun(runId);
telemetry.emit({
primaryBackend: serverConfig.primaryBackend,
- queryBackend: backends.queryBackend,
+ executorBackend: startupExecutorBackend,
lifecycleClass: 'cleanup',
capabilityState: 'supported',
category: 'success',
@@ -266,7 +269,7 @@ async function main() {
audit.lifecycle('shutdown-cleanup-failed', error.message);
telemetry.emit({
primaryBackend: serverConfig.primaryBackend,
- queryBackend: backends.queryBackend,
+ executorBackend: startupExecutorBackend,
lifecycleClass: 'cleanup',
capabilityState: 'supported',
category: 'cleanup-failed',
diff --git a/containers/bounded-query/query-entrypoint.py b/containers/enclave/script-entrypoint.py
similarity index 81%
rename from containers/bounded-query/query-entrypoint.py
rename to containers/enclave/script-entrypoint.py
index 5dac974cb..bcd729f48 100644
--- a/containers/bounded-query/query-entrypoint.py
+++ b/containers/enclave/script-entrypoint.py
@@ -1,5 +1,5 @@
#!/usr/local/bin/python3
-"""Materialize the assigned seed into bounded tmpfs, then run the query."""
+"""Materialize the assigned seed into bounded tmpfs, then run the script."""
import os
import runpy
@@ -9,7 +9,9 @@
SEED = Path("/awf/seed")
REPO = Path("/query/repo")
SCRIPT = "/awf/query-script.py"
+OUTPUT = Path("/awf/out")
shutil.copytree(SEED, REPO, symlinks=True)
+Path("/query/out").symlink_to(OUTPUT)
os.chdir("/query")
runpy.run_path(SCRIPT, run_name="__main__")
diff --git a/containers/bounded-query/broker/docker-client.js b/containers/enclave/script-executor/docker-client.js
similarity index 100%
rename from containers/bounded-query/broker/docker-client.js
rename to containers/enclave/script-executor/docker-client.js
diff --git a/containers/bounded-query/broker/docker-query-runner.js b/containers/enclave/script-executor/docker-script-runner.js
similarity index 87%
rename from containers/bounded-query/broker/docker-query-runner.js
rename to containers/enclave/script-executor/docker-script-runner.js
index 632f68d06..7743cc3c7 100644
--- a/containers/bounded-query/broker/docker-query-runner.js
+++ b/containers/enclave/script-executor/docker-script-runner.js
@@ -6,15 +6,15 @@ const {
buildRemoveArgs,
deriveQueryContainerSpec,
normalizeTimeoutMs,
-} = require('./query-runner-spec');
+} = require('./script-runner-spec');
/**
- * QueryRunner using the Docker daemon's default OCI runtime.
+ * ScriptRunner using the Docker daemon's default OCI runtime.
*
* The optional runtimeName is constructor-controlled so subclasses can select
* a fixed trusted runtime without accepting runtime data per invocation.
*/
-class DockerQueryRunner {
+class DockerScriptRunner {
constructor(config, deps = {}, runtimeName = undefined) {
this.config = config;
this.runtimeName = runtimeName;
@@ -41,11 +41,11 @@ class DockerQueryRunner {
async listContainerIds(args) {
const listed = await this.docker.runDocker(args, 30_000);
if (listed.exitCode !== 0) {
- throw new Error('Failed to reconcile bounded-query containers');
+ throw new Error('Failed to reconcile enclave-script containers');
}
const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean);
if (ids.some((id) => !/^[0-9a-f]{12,64}$/.test(id))) {
- throw new Error('Docker returned an invalid bounded-query container id');
+ throw new Error('Docker returned an invalid enclave-script container id');
}
return ids;
}
@@ -55,7 +55,7 @@ class DockerQueryRunner {
if (ids.length === 0) return;
const removed = await this.docker.runDocker(buildRemoveArgs(ids), 30_000);
if (removed.exitCode !== 0) {
- throw new Error('Failed to remove bounded-query containers');
+ throw new Error('Failed to remove enclave-script containers');
}
}
@@ -78,7 +78,7 @@ class DockerQueryRunner {
await this.serializeCleanup(() => this.removeListed(spec.invocationListArgs));
}
- async runQueryContainer(params) {
+ async runScriptContainer(params) {
const spec = this.spec(params.runId, params.invocationId);
const timeoutMs = normalizeTimeoutMs(
(params.timeoutMs ?? this.config.timeoutSeconds * 1000) + CLI_GRACE_MS,
@@ -108,4 +108,4 @@ class DockerQueryRunner {
}
}
-module.exports = { DockerQueryRunner };
+module.exports = { DockerScriptRunner };
diff --git a/containers/bounded-query/broker/broker.js b/containers/enclave/script-executor/executor-handler.js
similarity index 84%
rename from containers/bounded-query/broker/broker.js
rename to containers/enclave/script-executor/executor-handler.js
index d428f0269..1109ec25c 100644
--- a/containers/bounded-query/broker/broker.js
+++ b/containers/enclave/script-executor/executor-handler.js
@@ -2,18 +2,18 @@
const crypto = require('crypto');
const {
- CANONICAL_ERROR_JSON,
- canonicalOkJson,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
- validateBoundedQueryRequest,
-} = require('./protocol');
-const { createLedger } = require('./ledger');
-const { createRealClock, waitForBucket } = require('./scheduler');
+ CANONICAL_ERROR_RESPONSE_JSON,
+ canonicalSuccessJson,
+ parseAndValidateFiniteOutput,
+ informationChargeForSchema,
+ validateEnclaveScriptRequest,
+} = require('../../bounded-execution/finite-disclosure');
+const { createEnclaveInformationBudgetLedger } = require('../../bounded-execution/sensitivity-ledger');
+const { createRealClock, waitForBucket } = require('../../bounded-execution/fixed-timing');
const defaultWorkspace = require('./workspace');
/**
- * The trusted bounded-query broker (protocol v2).
+ * The trusted enclave executor request handler (protocol v2).
*
* Responsibilities, in order, for every request:
*
@@ -47,24 +47,23 @@ const defaultWorkspace = require('./workspace');
* any cross-invocation race in workspace creation/teardown/ledger access.
*/
-function createBroker(params) {
+function createExecutorHandler(params) {
const { config, seedMap, runId, audit } = params;
const workspace = params.workspace || defaultWorkspace;
if (!params.runner) {
- throw new Error('createBroker requires a trusted QueryRunner');
+ throw new Error('createExecutorHandler requires a trusted ScriptRunner');
}
const runner = params.runner;
const clock = params.clock || createRealClock();
- const ledger = params.ledger || createLedger(seedMap);
+ const ledger = params.ledger || createEnclaveInformationBudgetLedger(seedMap);
const telemetry = params.telemetry || { emit() {} };
const executorKind = params.executorKind || 'script';
const uniformTiming = params.uniformTiming === true;
if (executorKind !== 'script' && executorKind !== 'agent') {
- throw new Error('createBroker requires a known executor kind');
+ throw new Error('createExecutorHandler requires a known executor kind');
}
- // Trusted, executor-specific request grammar. The default is the bounded
- // *script* grammar, so the legacy bounded-query broker is unchanged.
- const validateRequest = params.validateRequest || validateBoundedQueryRequest;
+ // Trusted executor-specific request grammar; callers cannot replace it.
+ const validateRequest = params.validateRequest || validateEnclaveScriptRequest;
// Name of the single free-form payload field this executor accepts.
const payloadKey = params.payloadKey || 'script';
// Optional trusted exit-status → protected-audit category map. Categories
@@ -79,11 +78,11 @@ function createBroker(params) {
let invocationsUsed = 0;
let accepting = true;
- function emitQueryTelemetry(category) {
+ function emitInvocationTelemetry(category) {
telemetry.emit({
primaryBackend: config.primaryBackend,
- queryBackend: config.queryBackend,
- lifecycleClass: 'query',
+ executorBackend: config.executorBackend,
+ lifecycleClass: 'invocation',
capabilityState: 'supported',
category,
});
@@ -107,11 +106,11 @@ function createBroker(params) {
};
const rejectBeforeExecution = async (reason, detail, telemetryCategory = reason) => {
audit.failure(invocationId, reason, detail);
- emitQueryTelemetry(telemetryCategory);
+ emitInvocationTelemetry(telemetryCategory);
if (admissionStartMs !== undefined) {
await waitForBucket(admissionStartMs, clock.nowMs() - admissionStartMs, clock);
}
- safeRespond(CANONICAL_ERROR_JSON);
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
};
const validation = validateRequest(request);
@@ -133,7 +132,7 @@ function createBroker(params) {
// copying a seed or launching Python. Every invocation may declare a
// different schema; there is no separate per-query cap — only whether
// this charge fits the repository's remaining run balance.
- const charge = queryBitsForSchema(schema);
+ const charge = informationChargeForSchema(schema);
if (!ledger.tryDebit(repoKey, charge, executorKind)) {
await rejectBeforeExecution('bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`);
return;
@@ -167,7 +166,7 @@ function createBroker(params) {
failureReason = ['timeout', 'workspace-creation-overran-deadline'];
} else {
try {
- const run = await runner.runQueryContainer({
+ const run = await runner.runScriptContainer({
config,
runId,
invocationId,
@@ -188,7 +187,7 @@ function createBroker(params) {
// any non-regular replacement (symlink/FIFO/device/socket).
failureReason = ['unreadable-output'];
} else {
- const parsed = parseAndValidateQueryOutput(raw, schema);
+ const parsed = parseAndValidateFiniteOutput(raw, schema);
if (!parsed.ok) {
failureReason = ['nonconformant-output'];
} else {
@@ -234,8 +233,8 @@ function createBroker(params) {
// configured bucket — pathological infrastructure latency. Never emit a
// successful result at unbucketed timing.
audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason.join(':') : undefined);
- emitQueryTelemetry('timing-bucket-overflow');
- safeRespond(CANONICAL_ERROR_JSON);
+ emitInvocationTelemetry('timing-bucket-overflow');
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
} else if (canonicalResult !== undefined) {
audit.invocation({
invocationId,
@@ -244,13 +243,13 @@ function createBroker(params) {
bits: charge,
bucketMs,
});
- emitQueryTelemetry('success');
- safeRespond(canonicalOkJson(canonicalResult));
+ emitInvocationTelemetry('success');
+ safeRespond(canonicalSuccessJson(canonicalResult));
} else {
const category = failureReason ? failureReason[0] : 'unknown';
audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined);
- emitQueryTelemetry(category);
- safeRespond(CANONICAL_ERROR_JSON);
+ emitInvocationTelemetry(category);
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
}
}
@@ -276,9 +275,9 @@ function createBroker(params) {
* canonical result JSON, as soon as it is ready to send (which, for any
* invocation that reached workspace creation, is exactly at a timing
* bucket boundary — never earlier). The returned promise resolves once
- * all broker-side bookkeeping for the invocation (including workspace
+ * all server-side bookkeeping for the invocation (including workspace
* cleanup) is complete; it carries no value and exists only to let the
- * caller serialize/await broker shutdown.
+ * caller serialize/await handler shutdown.
*
* Requests are queued so at most one query runs at a time.
*/
@@ -291,7 +290,7 @@ function createBroker(params) {
};
if (!accepting) {
- safeRespond(CANONICAL_ERROR_JSON);
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
return Promise.resolve();
}
@@ -301,12 +300,12 @@ function createBroker(params) {
// against it.
if (invocationsUsed >= config.maxInvocations) {
audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`);
- emitQueryTelemetry('invocation-count-exhausted');
+ emitInvocationTelemetry('invocation-count-exhausted');
if (uniformTiming) {
const queued = lane.tail.then(async () => {
const startMs = clock.nowMs();
await waitForBucket(startMs, clock.nowMs() - startMs, clock);
- safeRespond(CANONICAL_ERROR_JSON);
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
});
lane.tail = queued.then(
() => undefined,
@@ -314,15 +313,15 @@ function createBroker(params) {
);
return queued;
}
- safeRespond(CANONICAL_ERROR_JSON);
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
return Promise.resolve();
}
invocationsUsed += 1;
const queued = lane.tail.then(() => execute(request, safeRespond)).catch((error) => {
audit.failure('queue', 'unexpected-error', error && error.message);
- emitQueryTelemetry('unexpected-error');
- safeRespond(CANONICAL_ERROR_JSON);
+ emitInvocationTelemetry('unexpected-error');
+ safeRespond(CANONICAL_ERROR_RESPONSE_JSON);
});
lane.tail = queued.then(
() => undefined,
@@ -331,7 +330,7 @@ function createBroker(params) {
return queued;
},
- /** Resolves when every admitted invocation has finished broker-side work. */
+ /** Resolves when every admitted invocation has finished server-side work. */
drain() {
return lane.tail;
},
@@ -346,4 +345,4 @@ function createBroker(params) {
};
}
-module.exports = { createBroker };
+module.exports = { createExecutorHandler };
diff --git a/containers/bounded-query/broker/gvisor-query-runner.js b/containers/enclave/script-executor/gvisor-script-runner.js
similarity index 76%
rename from containers/bounded-query/broker/gvisor-query-runner.js
rename to containers/enclave/script-executor/gvisor-script-runner.js
index f8f41d53c..c9f6d4969 100644
--- a/containers/bounded-query/broker/gvisor-query-runner.js
+++ b/containers/enclave/script-executor/gvisor-script-runner.js
@@ -1,12 +1,12 @@
'use strict';
-const { DockerQueryRunner } = require('./docker-query-runner');
+const { DockerScriptRunner } = require('./docker-script-runner');
const RUNSC_RUNTIME = 'runsc';
const RUNTIME_NAMES_FORMAT = '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}';
-/** QueryRunner using Docker with the fixed runsc OCI runtime. */
-class GvisorQueryRunner extends DockerQueryRunner {
+/** ScriptRunner using Docker with the fixed runsc OCI runtime. */
+class GvisorScriptRunner extends DockerScriptRunner {
constructor(config, deps = {}) {
super(config, deps, RUNSC_RUNTIME);
}
@@ -28,4 +28,4 @@ class GvisorQueryRunner extends DockerQueryRunner {
}
}
-module.exports = { GvisorQueryRunner, RUNSC_RUNTIME };
+module.exports = { GvisorScriptRunner, RUNSC_RUNTIME };
diff --git a/containers/bounded-query/broker/runtime-telemetry.js b/containers/enclave/script-executor/runtime-telemetry.js
similarity index 75%
rename from containers/bounded-query/broker/runtime-telemetry.js
rename to containers/enclave/script-executor/runtime-telemetry.js
index 31be15976..0abf7a799 100644
--- a/containers/bounded-query/broker/runtime-telemetry.js
+++ b/containers/enclave/script-executor/runtime-telemetry.js
@@ -4,26 +4,26 @@ const fs = require('fs');
const path = require('path');
const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const QUERY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
-const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'query', 'cleanup']);
+const EXECUTOR_BACKENDS = new Set(['docker', 'gvisor', 'sbx', 'mixed']);
+const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'invocation', 'cleanup']);
const CAPABILITY_STATES = new Set(['supported', 'unavailable', 'blocked']);
const CATEGORY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
function assertTelemetryValue(allowed, value, field) {
- if (!allowed.has(value)) throw new Error(`Invalid bounded-query telemetry ${field}`);
+ if (!allowed.has(value)) throw new Error(`Invalid enclave-script telemetry ${field}`);
}
function buildRuntimeTelemetryRecord(event) {
assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend');
- assertTelemetryValue(QUERY_BACKENDS, event.queryBackend, 'queryBackend');
+ assertTelemetryValue(EXECUTOR_BACKENDS, event.executorBackend, 'executorBackend');
assertTelemetryValue(LIFECYCLE_CLASSES, event.lifecycleClass, 'lifecycleClass');
assertTelemetryValue(CAPABILITY_STATES, event.capabilityState, 'capabilityState');
if (typeof event.category !== 'string' || !CATEGORY_PATTERN.test(event.category)) {
- throw new Error('Invalid bounded-query telemetry category');
+ throw new Error('Invalid enclave-script telemetry category');
}
return Object.freeze({
primaryBackend: event.primaryBackend,
- queryBackend: event.queryBackend,
+ executorBackend: event.executorBackend,
lifecycleClass: event.lifecycleClass,
capabilityState: event.capabilityState,
category: event.category,
@@ -41,7 +41,7 @@ function createRuntimeTelemetry(auditDir) {
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
} catch {
- process.stderr.write('[bounded-query] runtime telemetry unavailable\n');
+ process.stderr.write('[enclave-script] runtime telemetry unavailable\n');
try {
fs.closeSync(fd);
} catch {
diff --git a/containers/bounded-query/broker/sbx-capability-probe.js b/containers/enclave/script-executor/sbx-capability-probe.js
similarity index 100%
rename from containers/bounded-query/broker/sbx-capability-probe.js
rename to containers/enclave/script-executor/sbx-capability-probe.js
diff --git a/containers/bounded-query/broker/sbx-client.js b/containers/enclave/script-executor/sbx-client.js
similarity index 100%
rename from containers/bounded-query/broker/sbx-client.js
rename to containers/enclave/script-executor/sbx-client.js
diff --git a/containers/bounded-query/broker/sbx-query-runner-spec.js b/containers/enclave/script-executor/sbx-script-runner-spec.js
similarity index 94%
rename from containers/bounded-query/broker/sbx-query-runner-spec.js
rename to containers/enclave/script-executor/sbx-script-runner-spec.js
index 11a47705a..8414a60b4 100644
--- a/containers/bounded-query/broker/sbx-query-runner-spec.js
+++ b/containers/enclave/script-executor/sbx-script-runner-spec.js
@@ -4,7 +4,7 @@ const {
QUERY_MAX_FILE_BYTES,
QUERY_WORKSPACE_TMPFS_BYTES,
normalizeTimeoutMs,
-} = require('./query-runner-spec');
+} = require('./script-runner-spec');
const { REQUIRED_HARD_ISOLATION_FLAGS } = require('./sbx-capability-probe');
const SBX_CLI_GRACE_MS = 15_000;
@@ -56,7 +56,7 @@ function deriveSbxQuerySpec({ config, runId, invocationId }) {
'--ulimit-fsize', String(QUERY_MAX_FILE_BYTES),
'--mount-target', `${repoDir}:/awf/seed:ro`,
'--mount-target', `${scriptPath}:${config.queryScriptPath}:ro`,
- '--mount-target', `${outPath}:${config.queryMountDir}/out:rw`,
+ '--mount-target', `${outPath}:/awf/out:rw`,
'shell',
workspaceDir,
]),
@@ -65,7 +65,7 @@ function deriveSbxQuerySpec({ config, runId, invocationId }) {
'--user', `${config.queryUid}:${config.queryGid}`,
'--workdir', config.queryMountDir,
sandboxName,
- '/usr/local/bin/awf-run-query',
+ '/usr/local/bin/awf-run-enclave-script',
]),
stopArgs: freeze(['stop', sandboxName]),
removeArgs: freeze(['rm', '--force', sandboxName]),
diff --git a/containers/bounded-query/broker/sbx-query-runner.js b/containers/enclave/script-executor/sbx-script-runner.js
similarity index 89%
rename from containers/bounded-query/broker/sbx-query-runner.js
rename to containers/enclave/script-executor/sbx-script-runner.js
index 0f14c0975..dfe805c09 100644
--- a/containers/bounded-query/broker/sbx-query-runner.js
+++ b/containers/enclave/script-executor/sbx-script-runner.js
@@ -8,7 +8,7 @@ const {
SBX_CLI_GRACE_MS,
deriveSbxQuerySpec,
normalizeTimeoutMs,
-} = require('./sbx-query-runner-spec');
+} = require('./sbx-script-runner-spec');
function parseSandboxNames(stdout) {
let parsed;
@@ -27,7 +27,7 @@ function parseSandboxNames(stdout) {
return names;
}
-class SbxQueryRunner {
+class SbxScriptRunner {
constructor(config, deps = {}) {
this.config = config;
this.sbx = deps.sbx || defaultSbxClient;
@@ -45,7 +45,7 @@ class SbxQueryRunner {
const report = await this.probe(this.sbx);
if (!report.supported) {
throw new Error(
- 'sbx bounded-query backend is blocked: the installed sbx runtime cannot enforce all mandatory ' +
+ 'sbx enclave-script backend is blocked: the installed sbx runtime cannot enforce all mandatory ' +
`isolation controls (${report.missing.join(', ')}). No fallback is permitted.`,
);
}
@@ -63,7 +63,7 @@ class SbxQueryRunner {
async listRunSandboxes(runId) {
const spec = this.spec(runId, '000000000000000000000000');
const listed = await this.sbx.runSbx(spec.listArgs, 30_000);
- if (listed.exitCode !== 0) throw new Error('Failed to reconcile bounded-query sbx VMs');
+ if (listed.exitCode !== 0) throw new Error('Failed to reconcile enclave-script sbx VMs');
return parseSandboxNames(listed.stdout).filter((name) => name.startsWith(spec.runPrefix));
}
@@ -72,11 +72,11 @@ class SbxQueryRunner {
if (stopped.exitCode !== 0) {
const inventory = await this.sbx.runSbx(['ls', '--quiet'], 30_000);
if (inventory.exitCode !== 0 || inventory.stdout.split('\n').includes(name)) {
- throw new Error('Failed to stop bounded-query sbx VM');
+ throw new Error('Failed to stop enclave-script sbx VM');
}
}
const removed = await this.sbx.runSbx(['rm', '--force', name], 30_000);
- if (removed.exitCode !== 0) throw new Error('Failed to remove bounded-query sbx VM');
+ if (removed.exitCode !== 0) throw new Error('Failed to remove enclave-script sbx VM');
}
async reconcileRun(runId) {
@@ -92,7 +92,7 @@ class SbxQueryRunner {
await this.serializeCleanup(() => this.removeSandbox(sandboxName));
}
- async runQueryContainer(params) {
+ async runScriptContainer(params) {
const spec = this.spec(params.runId, params.invocationId);
const totalTimeoutMs = normalizeTimeoutMs(
(params.timeoutMs ?? this.config.timeoutSeconds * 1000) + SBX_CLI_GRACE_MS,
@@ -109,7 +109,7 @@ class SbxQueryRunner {
if (created.timedOut) {
result = created;
} else if (created.exitCode !== 0) {
- throw new Error('Failed to create bounded-query sbx VM');
+ throw new Error('Failed to create enclave-script sbx VM');
} else if (this.nowMs() >= deadlineMs) {
result = { exitCode: 124, timedOut: true, stdout: '', stderr: '' };
} else {
@@ -129,4 +129,4 @@ class SbxQueryRunner {
}
}
-module.exports = { SbxQueryRunner, parseSandboxNames };
+module.exports = { SbxScriptRunner, parseSandboxNames };
diff --git a/containers/bounded-query/broker/query-runner-spec.js b/containers/enclave/script-executor/script-runner-spec.js
similarity index 84%
rename from containers/bounded-query/broker/query-runner-spec.js
rename to containers/enclave/script-executor/script-runner-spec.js
index 81e54f7b8..9e3fc2a29 100644
--- a/containers/bounded-query/broker/query-runner-spec.js
+++ b/containers/enclave/script-executor/script-runner-spec.js
@@ -9,8 +9,6 @@ const QUERY_MAX_FILE_BYTES = 512 * 1024 * 1024;
/** Aggregate size limit for the query's writable tmpfs workspace in bytes. */
const QUERY_WORKSPACE_TMPFS_BYTES = 1024 * 1024 * 1024;
-const RUN_LABEL = 'awf.bounded-query.run';
-const INVOCATION_LABEL = 'awf.bounded-query.invocation';
const ENCLAVE_RUN_LABEL = 'awf.enclave.run';
const ENCLAVE_INVOCATION_LABEL = 'awf.enclave.invocation';
const TRUSTED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -41,9 +39,9 @@ function deriveQueryContainerSpec({ config, runId, invocationId, runtimeName })
throw new Error(`Unsupported OCI runtime in query runner: ${runtimeName}`);
}
- const runLabelKey = config.runLabelKey || RUN_LABEL;
- const invocationLabelKey = config.invocationLabelKey || INVOCATION_LABEL;
- const containerPrefix = config.containerPrefix || 'awf-query';
+ const runLabelKey = config.runLabelKey || ENCLAVE_RUN_LABEL;
+ const invocationLabelKey = config.invocationLabelKey || ENCLAVE_INVOCATION_LABEL;
+ const containerPrefix = config.containerPrefix || 'awf-enclave-script';
const containerName = `${containerPrefix}-${runId.slice(0, 12)}-${invocationId}`;
const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`;
const runLabel = `${runLabelKey}=${runId}`;
@@ -77,14 +75,14 @@ function deriveQueryContainerSpec({ config, runId, invocationId, runtimeName })
'--env', 'PYTHONDONTWRITEBYTECODE=1',
'--env', 'PYTHONUNBUFFERED=1',
'-v', `${hostInvocationDir}/repo:/awf/seed:ro`,
- '-v', `${hostInvocationDir}/out:${config.queryMountDir}/out:rw`,
+ '-v', `${hostInvocationDir}/out:/awf/out:rw`,
'-v', `${hostInvocationDir}/script.py:${config.queryScriptPath}:ro`,
];
if (runtimeName !== undefined) {
launchArgs.push('--runtime', runtimeName);
}
- launchArgs.push('--entrypoint', '/usr/local/bin/run-query', config.queryImage);
+ launchArgs.push('--entrypoint', '/usr/local/bin/run-enclave-script', config.queryImage);
return Object.freeze({
containerName,
@@ -99,14 +97,6 @@ function deriveQueryContainerSpec({ config, runId, invocationId, runtimeName })
});
}
-/** Compatibility helper for focused argument tests. */
-function buildQueryArgs(params) {
- return deriveQueryContainerSpec({
- ...params,
- runtimeName: params.runtimeName,
- }).launchArgs;
-}
-
function buildRemoveArgs(containerIds) {
return freezeArray(['rm', '-f', ...containerIds]);
}
@@ -115,11 +105,8 @@ module.exports = {
CLI_GRACE_MS,
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_RUN_LABEL,
- INVOCATION_LABEL,
QUERY_MAX_FILE_BYTES,
QUERY_WORKSPACE_TMPFS_BYTES,
- RUN_LABEL,
- buildQueryArgs,
buildRemoveArgs,
deriveQueryContainerSpec,
normalizeTimeoutMs,
diff --git a/containers/enclave/script-executor/script-runner.js b/containers/enclave/script-executor/script-runner.js
new file mode 100644
index 000000000..9eb0a9334
--- /dev/null
+++ b/containers/enclave/script-executor/script-runner.js
@@ -0,0 +1,53 @@
+'use strict';
+
+const { DockerScriptRunner } = require('./docker-script-runner');
+const { GvisorScriptRunner } = require('./gvisor-script-runner');
+const { SbxScriptRunner } = require('./sbx-script-runner');
+const {
+ QUERY_MAX_FILE_BYTES,
+ QUERY_WORKSPACE_TMPFS_BYTES,
+ deriveQueryContainerSpec,
+ normalizeTimeoutMs,
+} = require('./script-runner-spec');
+
+/**
+ * Trusted server interface for one-script-per-sandbox execution.
+ *
+ * @typedef {object} ScriptRunner
+ * @property {() => Promise} assertAvailable
+ * @property {(runId: string) => Promise} reconcileRun
+ * @property {(params: {
+ * runId: string,
+ * invocationId: string,
+ * timeoutMs?: number
+ * }) => Promise<{exitCode: number, timedOut: boolean, stdout: string, stderr: string}>} runScriptContainer
+ */
+
+/**
+ * Selects a runner only from AWF's normalized server configuration.
+ *
+ * Unknown values fail closed. In particular, gVisor never falls back to the
+ * daemon's default OCI runtime when runsc is unavailable.
+ *
+ * @returns {ScriptRunner}
+ */
+function createScriptRunner(config, deps = {}) {
+ if (config.executorBackend === 'docker') {
+ return new DockerScriptRunner(config, deps);
+ }
+ if (config.executorBackend === 'gvisor') {
+ return new GvisorScriptRunner(config, deps);
+ }
+ if (config.executorBackend === 'sbx') {
+ return new SbxScriptRunner(config, deps);
+ }
+ throw new Error(`Unsupported enclave-script backend: ${config.executorBackend}`);
+}
+
+module.exports = {
+ QUERY_MAX_FILE_BYTES,
+ QUERY_WORKSPACE_TMPFS_BYTES,
+ createScriptRunner,
+ deriveQueryContainerSpec,
+ normalizeTimeoutMs,
+};
diff --git a/containers/bounded-query/broker/workspace.js b/containers/enclave/script-executor/workspace.js
similarity index 92%
rename from containers/bounded-query/broker/workspace.js
rename to containers/enclave/script-executor/workspace.js
index 53ded94a6..2fd7e6425 100644
--- a/containers/bounded-query/broker/workspace.js
+++ b/containers/enclave/script-executor/workspace.js
@@ -2,7 +2,7 @@
const fs = require('fs');
const path = require('path');
-const { MAX_RESULT_BYTES } = require('./protocol');
+const { MAX_RESULT_BYTES } = require('../../bounded-execution/finite-disclosure');
/**
* Per-invocation private workspace management.
@@ -14,7 +14,8 @@ const { MAX_RESULT_BYTES } = require('./protocol');
*
* `/query` is backed by a size-limited tmpfs so the query cannot create
* unbounded numbers of files on the Docker host. The query writes its
- * answer to `/query/out`, which is a pre-created bind-mounted file whose
+ * answer to `/query/out`, which resolves to the pre-created `/awf/out` bind
+ * outside the workspace tmpfs so Docker cannot mask the result channel,
* contents the broker reads back from the host filesystem after the
* container exits.
*/
@@ -26,7 +27,8 @@ function invocationLayout(workDir, invocationId) {
root,
// The seed copy is mounted read-only at /awf/seed for the fixed entrypoint.
repoDir: path.join(root, 'repo'),
- // Pre-created empty file bound at queryMountDir/out so the query can write
+ // Pre-created empty file bound at /awf/out; the entrypoint links /query/out
+ // to it after Docker mounts the bounded /query tmpfs.
// its answer to the host filesystem; the broker reads it back after exit.
outPath: path.join(root, 'out'),
// The submitted script is bound read-only at queryScriptPath.
@@ -115,7 +117,7 @@ function createInvocationWorkspace(params) {
* Reads the query's result file defensively.
*
* `O_NOFOLLOW` plus an explicit regular-file check means a query cannot make
- * the broker read something else by replacing `/query/out` with a symlink,
+ * the server read something else by replacing `/awf/out`,
* FIFO, device, or socket. Anything unexpected returns `undefined`, which the
* caller maps to the canonical error result.
*/
diff --git a/containers/bounded-query/query-seccomp.json b/containers/enclave/seccomp.json
similarity index 99%
rename from containers/bounded-query/query-seccomp.json
rename to containers/enclave/seccomp.json
index b07c2b7d7..22eea2415 100644
--- a/containers/bounded-query/query-seccomp.json
+++ b/containers/enclave/seccomp.json
@@ -339,7 +339,7 @@
"writev"
],
"action": "SCMP_ACT_ALLOW",
- "comment": "Syscalls a stdlib-only python3 query needs. Derived from containers/agent/seccomp-profile.json minus the deny list below; kept in sync by src/bounded-query/query-seccomp.test.ts."
+ "comment": "Syscalls a stdlib-only python3 query needs. Derived from containers/agent/seccomp-profile.json minus the deny list below; kept in sync by src/enclave-script/query-seccomp.test.ts."
},
{
"names": [
diff --git a/docs/INTEGRATION-TESTS.md b/docs/INTEGRATION-TESTS.md
index a3e5f6e86..5d62967f8 100644
--- a/docs/INTEGRATION-TESTS.md
+++ b/docs/INTEGRATION-TESTS.md
@@ -54,6 +54,20 @@ The test suite is organized in three tiers:
| Smoke Tests | 4 | N/A | Per-workflow (scheduled + PR) |
| Build-Test | 8 | N/A | Per-workflow (PR + dispatch) |
+### Unified enclave coverage
+
+Legacy bounded smoke and runtime-matrix assets were removed from the owned workflow surface. Until a unified gh-aw enclave smoke workflow exists, coverage for the enclave MCP server and executor contracts stays local/unit-focused:
+
+- `src/services/enclave-mcp-service.test.ts`
+- `src/services/enclave-agent-service.test.ts`
+- `src/enclave/script-runner-spec.test.ts`
+- `src/enclave/agent-runner-spec.test.ts`
+- `src/enclave/manager.test.ts`
+- `src/enclave/mcp-server.test.ts`
+- `src/enclave/agent-mcp-server.test.ts`
+
+These cover the shared tool contract, gVisor routing assumptions, fail-closed `sbx` behavior, and the mcpg-only topology.
+
---
## What's Covered
diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md
index c4c7e4b9f..2204ce35e 100644
--- a/docs/awf-config-spec.md
+++ b/docs/awf-config-spec.md
@@ -82,7 +82,7 @@ following top-level properties. All are OPTIONAL:
| `logging` | object | Logging and diagnostics |
| `rateLimiting` | object | Egress rate limiting |
| `platform` | object | GitHub platform deployment type declaration |
-| `boundedQueries` | object | Bounded-query sandbox subsystem (see §14) |
+| `enclaves` | object | Unified private-repository enclave subsystem (see §14) |
Property-level constraints, types, and descriptions are defined
normatively by `docs/awf-config.schema.json`.
@@ -214,29 +214,33 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`).
- `platform.type` → *(config-only; maps to `AWF_PLATFORM_TYPE`)*
- `runner.topology` → *(config-only; sets runner deployment model — `standard` or `arc-dind`; when `arc-dind`, enables sysroot staging and emits RUNNER_TOOL_CACHE warnings)*
- `runner.sysrootImage` → *(config-only; sysroot init-container image for `arc-dind` topology; defaults to `/build-tools:`, where `container.imageRegistry` defaults to `ghcr.io/github/gh-aw-firewall`)*
-- `boundedQueries.enabled` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.privateRepos[]` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.runtime` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.timeout` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.memoryLimit` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.interpreter` → *(config-only; no CLI equivalent, see §14)*
-- `boundedQueries.maxInvocations` → *(config-only; no CLI equivalent, see §14)*
-- `boundedAgents.enabled` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.privateRepos[]` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.runtime` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.engine` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.profile` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.model` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.timeout` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.memoryLimit` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.cpuLimit` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.pidsLimit` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.tmpfsLimit` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.maxOutputBytes` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.maxTaskBytes` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.maxInvocations` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.maxModelRequests` → *(config-only; no CLI equivalent, see §15)*
-- `boundedAgents.maxModelTokens` → *(config-only; no CLI equivalent, see §15)*
+- `enclaves.enabled` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.privateRepos[]` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.enabled` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.runtime` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.image` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.timeout` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.memoryLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.cpuLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.pidsLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.maxScriptBytes` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.script.maxInvocations` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.enabled` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.runtime` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.image` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.engine` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.profile` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.model` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.timeout` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.memoryLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.cpuLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.pidsLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.maxTaskBytes` → *(config-only; no CLI equivalent, see §14)*
+- `enclaves.executors.agent.maxInvocations` → *(config-only; no CLI equivalent, see §14)*
When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts.
@@ -1584,995 +1588,91 @@ Each record follows the `blocked-request-diag/v` schema:
- The file is written to `AWF_TOKEN_LOG_DIR` alongside `token-usage.jsonl`
and is governed by the same artifact-retention policy.
-## 14. Bounded Queries
+## 14. Unified Enclaves
-### 14.1 Purpose
+The optional `enclaves` object defines AWF's sole supported private-repository execution surface. AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, maintains one shared per-repository ledger for the run, and exposes enabled executors only through compiler-launched `gh-aw-mcpg`.
-A *bounded query* lets an agent ask a trusted broker to run a short,
-agent-authored Python 3 script against a private repository and get back a
-value conforming to a finite response schema the agent declares up front —
-without the agent ever gaining network or filesystem access to that
-repository.
+### 14.1 Executors and shared configuration
-Every private repository configured for bounded queries carries one of four
-fixed **sensitivity categories**, each with an immutable maximum number of
-bits the broker may reveal about that repository across an entire AWF run
-(not per query):
+`enclaves.privateRepos` is the only trusted repository list. Every enabled executor shares it, and every admitted invocation debits the same live per-repository information budget.
-| Sensitivity | Run budget | Notes |
-|-------------|-----------:|-------|
-| `public` | unmetered | Still schema/operationally bounded, but responses are never debited against a ledger. |
-| `internal` | 64 bits/run | Default for legacy bare-string entries (§14.2). |
-| `confidential` | 8 bits/run | |
-| `sealed` | 0 bits/run | Can never fund even the cheapest possible query — never copies a seed or launches Python. |
+- **Script executor** — configured under `enclaves.executors.script`; launches a no-network, read-only, single-use Python sandbox.
+- **Agent executor** — configured under `enclaves.executors.agent`; launches a bounded single-use Copilot enclave whose only network peer is the dedicated API proxy.
-There is **no per-query cap**. Every invocation may declare an arbitrarily
-different response schema; the broker computes that invocation's maximum
-complete-transcript information charge (§14.3) and debits it from the
-repository's shared run balance *before* copying a seed or launching Python.
-An invocation is allowed iff its charge fits the remaining balance — a cheap
-boolean question and an expensive high-cardinality question both draw from
-the same budget, just at different rates. Charges are never refunded,
-regardless of outcome (success, failure, or timeout).
-`boundedQueries.maxInvocations` is a separate, independent operational limit
-(§14.2) unrelated to the bit ledger.
+At least one executor MUST be enabled when `enclaves.enabled` is `true`. `gvisor` requires an exactly registered `runsc` runtime and never falls back. `sbx` remains fail-closed for both executors until the audited capability proof lands.
-### 14.2 Configuration
+The agent executor additionally requires `enableApiProxy`, a configured provider route for its fixed engine/profile, a configured `model`, and the absence of `enableDind`. AWF validates those requirements before repository staging.
-The root object MAY contain a `boundedQueries` section:
+### 14.2 MCP-only tool surface
-```json
-{
- "boundedQueries": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/my-private-repo", "sensitivity": "internal" },
- { "repo": "my-org/public-docs", "sensitivity": "public" }
- ],
- "runtime": "docker",
- "timeout": 30,
- "memoryLimit": "512m",
- "interpreter": "python3",
- "maxInvocations": 32
- }
-}
+The primary agent reaches private-repository execution only through these MCP tools:
+
+```text
+enclave_run_script({
+ privateRepo: "owner/repo",
+ schema: ,
+ script:
+})
+
+enclave_run_agent({
+ privateRepo: "owner/repo",
+ schema: ,
+ prompt:
+})
```
-| Field | Type | Constraints | Default |
-|-------|------|-------------|---------|
-| `enabled` | boolean | — | `false` |
-| `privateRepos` | array | Non-empty and unique (by repo slug, case-insensitively) when `enabled` is `true`. Each entry is either an object `{ "repo": "owner/repo", "sensitivity": "public" \| "internal" \| "confidential" \| "sealed" }`, or (one-release legacy compatibility) a bare `owner/repo` string, normalized to `{ repo, sensitivity: "internal" }` with a warning. Each `repo` MUST be a bare `owner/repo` slug — no scheme/host (`://`), path traversal (`..`), query string (`?`), fragment (`#`), wildcard (`*`), or extra path segments. | `[]` |
-| `runtime` | string | One of `"docker"`, `"gvisor"`, `"sbx"`. The `sbx` value is a fail-closed preview blocked unless its executable capability proof satisfies every mandatory isolation control. | `"docker"` |
-| `timeout` | integer | `1`–`540` seconds (the final minute of the 10-minute response bucket is reserved for termination, validation, and cleanup; §14.3) | `30` |
-| `memoryLimit` | string | Docker-style memory limit, e.g. `"512m"`, `"1g"` | `"512m"` |
-| `interpreter` | string | Only `"python3"` is currently supported | `"python3"` |
-| `maxInvocations` | integer | `1`–`10000`; an independent operational cap, unrelated to the per-repository bit ledger | `32` |
-
-Property-level constraints are defined normatively by the `boundedQueries`
-subschema in `docs/awf-config.schema.json`.
-
-**Legacy `privateRepos` string entries.** A bare `owner/repo` string is
-accepted for one release for backward compatibility and is normalized to
-`{ repo, sensitivity: "internal" }`, emitting a warning
-(`boundedQueries.privateRepos entry "..." is a legacy bare string...`) through
-the same warning channel other config normalization uses. New configuration
-SHOULD use the explicit object form so the intended sensitivity is never
-left implicit.
-
-**Mapping:** every `boundedQueries.*` field is *(config-only; no CLI
-equivalent)*. There is no `--bounded-queries-*` CLI flag family. The config-file
-value is passed through `config-mapper.ts` and normalized (defaults applied
-via `src/types/bounded-query-options.ts`'s `BOUNDED_QUERY_DEFAULTS`, legacy
-string entries normalized in `src/parsers/bounded-query-parser.ts`) into
-`WrapperConfig.boundedQueries`. Only an explicit `enabled: true` normalizes to
-an enabled config; omission or any other value normalizes to
-`enabled: false`.
-
-When `enabled` is `false` or the section is absent, AWF stages nothing, starts
-no broker, mounts no socket, sets no environment variable, installs no CLI,
-and generates no skill: behaviour is byte-identical to a run without the
-section.
-
-**Preflight (fail-closed).** With `enabled: true`, AWF aborts before the
-primary agent starts when: `privateRepos` is empty or contains an unsafe or
-duplicated slug; `runtime` is `"gvisor"` and the `runsc` OCI runtime is not
-registered with the Docker daemon; `runtime` is `"sbx"` and the executable
-capability proof cannot establish every mandatory no-network and resource
-bound; a Docker/gVisor query resolves to a non-`unix://` Docker host, which a
-`network_mode: none` broker cannot reach; the interpreter or a limit is
-unsupported; `timeout` exceeds 540
-seconds — the 10-minute response bucket reserves its final minute for Docker
-termination, result validation, container removal, and workspace cleanup; no
-staging credential is present in
-`GH_TOKEN`/`GITHUB_TOKEN`; or any seed cannot be materialized and verified.
-
-**`sbx` query backend status.** The configuration value and broker-owned
-`SbxQueryRunner` boundary are present, but support is fail-closed as of the
-audited Docker Sandboxes CLI `v0.37.1`. The executable broker capability probe
-uses `sbx version`, `sbx create --help`, and `sbx exec --help`, exits non-zero,
-and reports missing guarantees as JSON. Although this release supports
-`sbx create --name --cpus --memory --template`, read-only same-path mounts,
-`sbx exec --user --workdir`, `sbx ls --json`, `sbx stop`, and
-`sbx rm --force`, it has no enforceable per-VM `network=none`, PID, disk,
-per-file size, or explicit guest mount-target control. Local/kit network denies
-are not sufficient because organization governance can replace them. AWF
-therefore aborts before staging or Compose assembly, passes no Docker socket or
-sbx credential to the broker, and never falls back to Docker/gVisor. Enabling
-launch requires all missing controls plus a digest-pinned, Python
-standard-library-only AWF query template/bootstrap.
-
-**Independent runtime matrix.** `container.containerRuntime` selects the primary
-agent while `boundedQueries.runtime` independently selects a fresh query
-sandbox. Every accepted invocation creates one new sandbox and destroys it
-before response. The current capability matrix is:
-
-| Primary agent | Docker query | gVisor query | sbx query |
-|---|---|---|---|
-| Docker | Supported with Docker | Supported with registered `runsc` | Blocked |
-| gVisor | Supported with primary `runsc` | Supported with registered `runsc` | Blocked |
-| sbx | Supported after primary ingress probe | Supported after primary ingress and `runsc` probes | Blocked |
-
-Unavailable cells abort at preflight and never stage. A blocked sbx query is an
-expected security result, not runtime success. `"runtime": "sbx"` is both the
-explicit experimental selection and a requirement to pass every executable
-probe; it never authorizes fallback.
-
-**Runtime telemetry.** Telemetry records contain exactly `primaryBackend`,
-`queryBackend`, `lifecycleClass`, `capabilityState`, and `category`. They MUST
-NOT contain repository data or identifiers, scripts, outputs, paths, tokens,
-ingress capabilities, or daemon credentials.
-
-Promotion of sbx queries requires real-VM proof of no network/lateral access,
-all resource bounds, mount-target isolation, credential/state separation,
-canonical output behavior, and cleanup after timeout, resource failure, and
-interruption, plus a digest-pinned AWF Python-only template. Version/help
-probing alone is insufficient.
-
-The seed map the broker reads carries each repository's trusted
-`sensitivity` alongside its opaque seed id — the map is built entirely from
-AWF configuration, so a request can never choose or override its own
-repository's sensitivity or run budget.
-
-### 14.3 Request/Result Protocol (v2)
-
-`src/bounded-query/protocol.ts` defines the wire protocol. The broker restates
-it in `containers/bounded-query/broker/protocol.js` because it runs from its
-own container image and cannot import AWF's TypeScript sources; the two
-implementations are pinned together by
-`src/bounded-query/protocol-parity.test.ts`, which runs one large shared
-vector table (schemas, values, requests, and query results) through both.
-
-**Request.** A bounded-query request is a JSON object with exactly three
-fields:
+Both tool schemas are closed (`additionalProperties: false`). A call can never provide or override images, runtimes, models, engines, profiles, mounts, network settings, credentials, repository catalogs, budgets, timeouts, or any other trusted control.
-```json
-{
- "privateRepo": "my-org/my-private-repo",
- "schema": { "type": "boolean" },
- "script": ""
-}
-```
+The primary agent MUST NOT receive a broker socket, wrapper binary, direct server URL, capability token, repository seed, ledger state, or alternate enclave transport.
-- `privateRepo` MUST match the same `owner/repo` slug rule as
- `boundedQueries.privateRepos` entries (§14.2).
-- `schema` MUST be a valid document in the finite schema DSL below.
-- `script` MUST be non-empty and at most 64 KiB (`MAX_SCRIPT_BYTES`). Script
- and schema sizes are enforced independently on their raw UTF-8 bytes; JSON
- escaping does not reduce either allowance.
-
-**Result.** A successful query result is the canonical envelope
-`{"status":"ok","result":}`, where `` conforms exactly to the
-request's declared `schema`. Every failure mode — invalid request,
-disallowed repository, exhausted bit budget, launch failure, timeout, crash,
-non-conformant query output, or internal error — collapses to the single
-canonical `{"status":"error"}`, indistinguishable from one another by
-design.
-
-#### The finite schema DSL
-
-The response schema is a deliberately finite, agent-authored algebra — **not**
-general JSON Schema. Every invocation may use a different schema. Supported
-node types:
-
-| type | shape | notes |
-|------|-------|-------|
-| `const` | `{"type":"const","value":}` | exactly one fixed value |
-| `boolean` | `{"type":"boolean"}` | `true` or `false` |
-| `enum` | `{"type":"enum","values":[,...]}` | unique literals, all the same JSON type |
-| `integer` | `{"type":"integer","minimum":N,"maximum":M}` | inclusive bounded range, safe-integer bounds only |
-| `object` | `{"type":"object","fields":{"name":,...}}` | every declared field required; no extra properties |
-| `tuple` | `{"type":"tuple","items":[,...]}` | fixed-length, independently-typed positions |
-| `array` | `{"type":"array","items":,"length":N}` | fixed length, single uniform item schema |
-| `union` | `{"type":"union","variants":{"tag":,...}}` | value is `{"tag":"","value":<...>}`; variants are disjoint by tag |
-
-A literal (in `const`/`enum`) is a JSON string (at most `MAX_LITERAL_STRING_BYTES`
-= 64 bytes UTF-8, no control characters), a safe integer, a boolean, or
-`null`. There is no way to express an unbounded string, a float, a regex,
-recursion, `$ref`, an optional field, `additionalProperties`, or an
-untagged/overlapping union — these are structurally impossible to write, not
-merely disallowed by a validator.
-
-This is a deliberately safe *subset* of what a general schema language could
-express, chosen so every schema has a computable, bounded cardinality and a
-linear-time validator with no backtracking. If a future requirement needs a
-richer construct, it must justify a new bounded primitive rather than
-weakening these bounds. Every schema is additionally bounded structurally:
-
-| Bound | Constant | Value |
-|-------|----------|------:|
-| Max nesting depth | `MAX_SCHEMA_DEPTH` | 6 |
-| Max total schema nodes | `MAX_SCHEMA_NODES` | 64 |
-| Max serialized schema size | `MAX_SCHEMA_BYTES` | 4096 bytes |
-| Max `enum` values | `MAX_ENUM_VALUES` | 4096 |
-| Max `object` fields | `MAX_OBJECT_FIELDS` | 16 |
-| Max `tuple` items | `MAX_TUPLE_ITEMS` | 16 |
-| Max `array` length | `MAX_ARRAY_LENGTH` | 64 |
-| Max `union` variants | `MAX_UNION_VARIANTS` | 16 |
-| Max literal string length | `MAX_LITERAL_STRING_BYTES` | 64 bytes |
-
-In practice, `MAX_SCHEMA_BYTES` is often the binding constraint for wide
-`enum`/`object`/`tuple` schemas well before the corresponding count bound is
-reached (e.g. a numeric `enum` of exactly `MAX_ENUM_VALUES` values already
-exceeds `MAX_SCHEMA_BYTES` once serialized).
-
-#### Budget: cardinality and bit charge
-
-"Schema cardinality" is the number of distinguishable values a schema
-admits — 2 for `boolean`, `N` for an `N`-member `enum`, the product of field
-cardinalities for `object`/`tuple`/`array`, the sum of variant cardinalities
-for `union`, and 1 for `const`. Cardinality is computed with unbounded
-(`BigInt`) arithmetic so no schema can overflow it into an incorrect small
-number.
-
-Every accepted invocation's information charge is:
+### 14.3 Topology, gateway contract, and readiness
-```text
-charge = RESULT_STATUS_BIT_COST (1 — ok/error is itself observable)
- + ceil(log2(schema cardinality)) (the declared response schema)
- + TIMING_BUCKET_BITS (3 — six timing buckets, §14.3.1)
-```
+`enclave-mcp-server` joins only the private `awf-enclave-mcp-control` network. The compiler launches `gh-aw-mcpg`, labels it for the run, and passes AWF the private gateway endpoint plus a run-unique capability/identity handoff. The server is reachable **only** through that gateway.
-`RESULT_STATUS_BIT_COST` is `1`; `TIMING_BUCKET_BITS` is
-`ceil(log2(TIMING_BUCKETS_MS.length))` = `ceil(log2(6))` = `3`. The cheapest
-possible schema (`const`, cardinality 1) still charges `1 + 0 + 3 = 4` bits —
-this is the practical floor a repository's remaining balance is checked
-against to decide whether it can fund *any* further invocation at all.
+When the agent executor is enabled, each invocation joins only the dedicated `internal` `awf-enclave-agent` network. Its sole reachable peer is the dedicated enclave API proxy. Squid, the primary agent, the general API proxy, safe outputs, the MCP gateway, and the MCP server itself are excluded from that network.
-The charge is computed and the ledger is debited **before** a seed is
-copied or Python is launched (§14.2, §14.7); it is never refunded regardless
-of the invocation's outcome, because the broker committed to revealing up to
-that many bits of signal the moment it decided to run.
+The rollout contract depends on both upstream projects:
-#### 14.3.1 Response-timing buckets
+1. `github/gh-aw#50920` — compiler support for the enclave upstream, capability handoff, identity label, endpoint propagation, and timeout handoff.
+2. `github/gh-aw-mcpg#10784` — late backend rediscovery so an initially unavailable HTTP backend can appear after gateway startup.
+3. MCP Gateway spec **1.15.0** and the **first mcpg release after v0.4.8 containing it**.
-A query's raw completion latency is itself a secret-dependent signal — a
-script that raises early on one code path and runs to completion on another
-leaks information purely through wall-clock time, independent of the
-declared schema. The broker makes every *launched* invocation's observable
-response time land on one of six fixed boundaries, using a monotonic clock
-(`process.hrtime.bigint()`, never `Date.now()`, so system clock adjustments
-cannot shift a response across a boundary):
+While the backend is still starting, mcpg may return retryable HTTP `503 backend_unavailable`. AWF retries `initialize` with bounded backoff until `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` expires, then fails closed before the primary agent starts.
-```text
-TIMING_BUCKETS_MS = [10ms, 100ms, 1s, 10s, 60s, 600s]
-```
+### 14.4 Shared ledger and disclosure
-The broker returns at the first bucket boundary at or after the invocation's
-processing (execution + output validation + container removal + workspace
-teardown) actually completes. A public 5ms host-scheduler tolerance covers
-ordinary timer jitter. If a selected boundary has already passed, or a timer
-wakes more than 5ms late, the broker re-resolves and pads to the next fixed
-boundary rather than responding at the late, continuously varying time. This is
-included in the information budget as `TIMING_BUCKET_BITS` (3 bits — for six
-buckets) whether or not the script's own answer would otherwise convey any
-signal, because latency alone is observable and must be paid for like any
-other channel.
-
-**Cleanup is included in the bucketed measurement.** Repository size and
-tree shape can affect container and workspace teardown, so the broker
-completes cleanup before measuring elapsed time and selecting the response
-bucket. Invocations are serialized; consequently a queued request cannot
-observe a preceding invocation's unaccounted cleanup duration. Cleanup
-failure maps to canonical error and is recorded only in the protected audit
-log.
-
-**Fail-closed timing overflow.** `boundedQueries.timeout` is capped at 540
-seconds, reserving the final minute before the 600-second boundary for Docker
-termination, result validation, container removal, and workspace cleanup. If
-pathological infrastructure overhead nevertheless pushes total processing or
-a late scheduler wake past the last boundary, the broker discards even an
-otherwise-valid successful result and returns the canonical error. This is a
-deliberate, tested (`broker.test.ts`) fallback, not a normal code path.
-
-### 14.4 Canonical Failure Closure
-
-Every failure mode — an invalid request, a disallowed repository, an
-exhausted bit budget, an exhausted `maxInvocations` count, a launch failure,
-a timeout, a script crash, non-conformant query output, a timing-bucket
-overflow, or an internal broker error — collapses to the single canonical
-`{"status":"error"}`. Failures are indistinguishable from each other by
-design: the agent cannot infer which failure mode occurred from the
-response alone.
-
-### 14.5 Strict, Non-Schema Result Parsing and Post-Execution Validation
-
-Result parsing intentionally does **not** execute a general-purpose JSON
-Schema validator against the (potentially attacker-influenced) raw query
-output text. `strictParseJson` enforces well-formedness with a small,
-linear-time, non-backtracking hand-written grammar — rejecting, rather than
-throwing, on:
-
-- malformed JSON of any kind;
-- duplicate object keys;
-- any leading or trailing content outside the single JSON value; and
-- invalid UTF-8 or invalid JSON string escapes.
-
-The parsed value is then validated against the **exact** schema the request
-declared (`validateValueAgainstSchema`) — wrong type, out-of-range integer,
-an undeclared enum member, extra or missing object fields, the wrong
-tuple/array length, or an unrecognized union tag are all rejected. A value
-that passes validation is canonically re-serialized
-(`canonicalizeSchemaValue`) before being wrapped in the `{"status":"ok",...}`
-envelope, so the exact byte layout the query wrote (whitespace, key order,
-duplicate-safe encoding) never reaches the agent — only a canonical
-re-encoding of the validated value does.
-
-Raw query bytes, stdout, stderr, and exit status never reach the agent under
-any circumstance, success or failure.
-
-### 14.6 Offline Staging
-
-Before any configuration is generated and before any container exists, AWF
-runs a trusted host-side staging phase (`src/bounded-query/staging.ts`):
-
-1. resolves the staging credential from `GH_TOKEN` or `GITHUB_TOKEN`;
-2. clones each configured repository from an AWF-constructed
- `https://github.com//.git` URL into a run-unique, opaque seed
- directory under a dedicated per-run private root outside `/tmp`, the
- workspace, mounted home/tool directories, and configured agent mounts. The
- credential is passed
- only through a `GIT_ASKPASS` helper reading it from the child process
- environment — never in argv, never in the URL, never in a log line, and
- never in the generated compose file;
-3. records the staged commit for protected audit state;
-4. scrubs the seed: remotes, remote-tracking refs, credential helpers, hooks,
- alternates, worktree links, reflogs, and `FETCH_HEAD` are removed and
- `.git/config` is replaced with a minimal, credential-free file;
-5. rejects repositories that declare submodules (`.gitmodules` or
- `.git/modules`) and any checkout whose `.git` is a symlink or a gitdir
- pointer — both are external references a query must never resolve;
-6. strips every write bit from the seed and verifies the result;
-7. deletes the askpass helper and the isolated staging `HOME`, so no staging
- artifact survives into the broker/agent phase.
-
-The generated seed map (`{ repo, seedId, sensitivity }` per entry) carries
-each repository's trusted `sensitivity` alongside its opaque seed id; this
-map is the broker's *only* source of sensitivity information — a request
-field can never supply or override it.
-
-Staging failure aborts the run. There is no fallback clone or fetch anywhere
-else in the system: neither the broker nor a query has a network path. A
-`sealed` (0-bit) repository is still staged like any other (so its
-configuration is validated the same way), but its run budget structurally
-guarantees the broker never copies that seed or launches Python for it.
-
-### 14.7 Trusted Broker and Query Sandbox
-
-The broker runs as an optional Docker Compose service
-(`bounded-query-broker`, container `awf-bounded-query-broker`). For Compose
-agents it uses `network_mode: none`; its entire surface is one Unix socket in a
-directory bind-mounted into the agent. For an sbx primary agent, trusted
-preflight first executes a disposable-sandbox probe of Unix-socket passthrough.
-If that probe succeeds, the same socket transport is used. Otherwise the
-broker is attached only to a dedicated Docker `internal` network with one
-ephemeral port narrowly published on host `127.0.0.1`. It is never attached to
-`awf-net`, `awf-ext`, Squid, DNS, or an internet-routed network. It also
-receives the resolved Docker socket so it can launch queries; that path is
-never placed in the agent's environment or volumes.
-
-The sbx endpoint requires a random per-run capability read by the broker from
-broker-private control state. A separate one-shot probe capability proves the
-actual sandbox can reach the endpoint before the primary agent starts. Both
-capabilities are absent from generated skills, Compose and audit artifacts,
-logs, query environments, and query launch arguments. The broker exposes no
-health or diagnostic route: both transports use the exact same `POST /query`
-framing, limits, canonical result bytes, scheduler/timing buckets, and audit
-path. Authentication, malformed framing, oversized requests, and internal
-failures all collapse to `{"status":"error"}`.
-
-The broker maps a normalized `owner/repo` id through the AWF-generated seed
-map to an opaque seed directory and its trusted sensitivity. Callers never
-supply a path, URL, ref, mount, image, command, environment, runtime, limit,
-or sensitivity. For each request that passes schema validation and clears
-its repository's remaining bit ledger (in that order — an invalid schema or
-an unaffordable charge is rejected before any seed is touched), the broker
-creates a fresh, full, private writable copy of exactly one seed and
-launches one query container with a fixed argument vector:
-
-- `--network none`, `--read-only`, `--user 65534:65534`, `--cap-drop ALL`,
- `--security-opt no-new-privileges:true`, and a restrictive seccomp profile
- (`containers/bounded-query/query-seccomp.json`);
-- memory, swap, CPU, PID, open-file, and file-size bounds plus the configured
- wall-clock timeout;
-- exactly two mounts: the invocation's private tree at `/query` (containing
- `repo/`, and where the query writes `out`) and the submitted script at the
- fixed read-only path `/awf/query-script.py`;
-- no Docker socket, no seed parent, no other repository, no workspace, no
- credentials, and no prior invocation's data.
-
-The invocation's workspace is torn down before the fixed timing bucket is
-selected (§14.3.1), so cleanup duration remains inside the charged timing
-channel. A cleanup failure produces canonical error and is recorded in the
-protected audit log (`reason: 'cleanup-failed'`). Repository mutations are
-ephemeral and are never returned or persisted. The result file is opened
-with `O_NOFOLLOW` and must
-be a regular file within the size cap, so replacing `/query/out` with a
-symlink, FIFO, device, or socket cannot make the broker read anything else.
-
-Query stdout/stderr is capped and discarded — never parsed, never returned,
-never logged in a form reachable by the agent. Failure reasons (with
-protected detail, e.g. `repo-not-allowed`, `bit-budget-exhausted`,
-`invalid-request`, `query-launch-failed`, `timing-bucket-overflow`,
-`cleanup-failed`) are written only below the dedicated broker-private root,
-which is mounted into the broker alone.
-
-### 14.8 Agent Interface
-
-When bounded queries are enabled, a Compose agent receives exactly two bind
-mounts — the broker socket directory (read-write) and a generated skill/wrapper
-directory (read-only) — plus three environment variables
-(`AWF_BOUNDED_QUERY_SOCKET`, `AWF_BOUNDED_QUERY_SKILL`,
-`AWF_BOUNDED_QUERY_REPOS`, the last a comma-separated list of configured repo
-slugs only — never sensitivities or budgets). GitHub tokens are removed from
-the agent environment whenever bounded queries are enabled, independently of
-the API and DIFC proxies.
-
-`containers/agent/bounded-query-wrapper.sh` is installed on the agent's `PATH`
-as `bounded-query` (protocol v2). It accepts only `--repo` once, `--schema`
-once (a JSON document, at most `MAX_SCHEMA_BYTES` bytes), and the script on
-stdin; every other option, the `--flag=value` form, and positional arguments
-are rejected without contacting the broker. It always prints exactly one
-canonical JSON line, writes nothing to stderr, and exits `0` — for both
-outcomes and for every failure, including transport failures, which produce
-`{"status":"error"}` locally. The wrapper cannot itself validate the
-schema's structure, cardinality, or bit charge (that is the trusted
-broker's job, enforced before it copies a seed or launches Python); its only
-responsibilities are enforcing the fixed CLI shape, base64url-encoding the
-schema into a request header, transporting the script body unmodified, and
-passing the broker's response through unmodified.
-
-An sbx agent receives only the generated skill/wrapper directory as a read-only
-mount. If Unix passthrough was proven, it additionally receives the socket
-directory read-only and `AWF_BOUNDED_QUERY_SOCKET`. Otherwise it receives
-`AWF_BOUNDED_QUERY_ENDPOINT` and `AWF_BOUNDED_QUERY_CAPABILITY`. It never
-receives the broker-private root, Docker socket, seeds, work/control/audit
-state, seed map, probe capability, or query launch authority.
-
-The generated `SKILL.md` is written under the run-specific ingress root and
-mounted read-only at `/run/awf-bounded-query-skill/SKILL.md`. It documents,
-per configured repository, its sensitivity and run budget (e.g. `` `octo/alpha`
-— 64 bits/run (`internal`) ``), the finite schema DSL, the bit-charge
-formula, the timing buckets, and the operational `maxInvocations` limit —
-so an agent can design informed, low-cardinality questions. AWF deliberately
-does **not** mount it into `$HOME/.copilot/skills` or the workspace's
-`.github/skills`: Docker would create the mount point inside host user state
-or inside the checked-out workspace. Agents therefore discover it through
-`AWF_BOUNDED_QUERY_SKILL` rather than through automatic skill discovery. This
-is a documented limitation, not an oversight.
-
-All seeds, invocation workspaces, the seed map, broker control state, and
-protected audit data live below
-`/var/tmp/awf-bounded-query-private--/`. Only the disjoint
-`/var/tmp/awf-bounded-query-ingress--/run/` (when Unix
-transport is selected) and generated skill/wrapper directory are agent-visible
-through explicit bind mounts. Before
-credential-bearing staging, AWF resolves each path through
-its longest existing ancestor (following symlinks) and rejects any private-root
-overlap with the union of Docker, gVisor, and sbx agent-visible mounts,
-including `/tmp`, the workspace, custom volumes, and whitelisted home tool
-directories. Docker-in-Docker host-path translation is checked and applied to
-the private broker mounts and ingress mounts symmetrically.
-
-An sbx host that cannot create the disposable capability probe or cannot reach
-the selected ingress from the actual primary sandbox fails preflight before
-the agent command starts. Transport never silently downgrades after selection.
-Query execution remains limited to the Docker and gVisor runners; this ingress
-support does not execute query sandboxes inside sbx.
-
-### 14.9 Protocol v1 Compatibility
-
-Protocol v1 (three fixed outcomes plus the reserved `"ERROR"` sentinel, no
-schema, no sensitivity, no bit ledger) is superseded by v2. There is no
-runtime v1/v2 auto-negotiation in the current wrapper or broker — both are
-deployed together as part of the same AWF release, and the wrapper always
-sends `X-AWF-Query-Version: 2`. A safe compatibility translation for legacy
-v1 three-outcome calls (mapping a fixed three-value `enum` schema to the old
-`outcomes` shape) is a natural extension point if a future release needs to
-accept both wire versions from mismatched wrapper/broker builds, but is not
-implemented today because AWF always deploys the wrapper and broker as a
-matched pair.
-
-### 14.10 Residual Channels and Limits
-
-- Every launched invocation's disclosure is bounded by its own declared
- schema's charge (§14.3) — not a fixed per-invocation cap — debited from its
- repository's run budget; `public` repositories are schema/operationally
- bounded but not bit-metered.
-- `maxInvocations` counts every response, including rejections, as an
- independent operational limit unrelated to the bit ledger.
-- Response timing is bucketed to one of six fixed boundaries and charged as
- part of the budget (§14.3.1); container and workspace cleanup complete
- before the bucket is selected (§14.3.1, §14.7).
-- Per-invocation aggregate disk usage is bounded by the wall-clock timeout and
- a per-file size limit rather than a hard filesystem quota.
-- The query rootfs is the broker image, so it also contains a Node runtime and
- the Docker CLI. Both are inert inside a query: there is no network, no
- Docker socket, no capability, and the entrypoint is fixed to `python3`.
-
-## 15. Bounded Agents
-
-### 15.1 Purpose
-
-A *bounded agent* is the agentic sibling of a bounded query (§14). Instead of
-running an agent-authored Python script, a trusted broker runs a configured,
-pinned native coding-agent engine inside a single-use *enclave* that reads one
-immutable repository seed read-only, reaches its model only through the AWF API
-proxy, and must reduce its work to one value conforming to a finite response
-schema the caller declared up front.
-
-Bounded agents exist for questions that need judgment or multi-step reading
-rather than a deterministic script, while keeping exactly the same disclosure
-bound: the caller observes only `{"status":"ok","result":}` or
-`{"status":"error"}`.
-
-Bounded agents reuse §14's sensitivity categories and budget table verbatim,
-but they never share a **ledger**: each subsystem runs its own broker with its
-own seed map in its own private root, so spending on one can never consume the
-other's remaining balance. The remaining balance is never disclosed to the
-caller in any form.
-
-The feature is **config-only**: there are no `--bounded-agents-*` CLI flags.
-
-### 15.2 Configuration
-
-The root object MAY contain a `boundedAgents` section:
+Script and agent calls debit the same live per-repository balance and share one AWF-owned admission lane. Switching executor kinds never resets or forks the ledger.
-```json
-{
- "boundedAgents": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/private-service", "sensitivity": "internal" }
- ],
- "runtime": "docker",
- "engine": "copilot",
- "model": "gpt-4o-mini",
- "timeout": 120,
- "memoryLimit": "512m",
- "cpuLimit": "1",
- "pidsLimit": 128,
- "tmpfsLimit": "64m",
- "maxOutputBytes": 8192,
- "maxTaskBytes": 4096,
- "maxInvocations": 8
- }
-}
-```
+`enclave_run_agent` necessarily sends repository-derived content to the configured model provider through the dedicated API proxy. The ledger bounds what the **calling agent** learns; it does not bound what the **provider** sees.
-| Field | Type | Default | Notes |
-|-------|------|---------|-------|
-| `enabled` | boolean | `false` | Only an explicit `true` enables the subsystem. |
-| `privateRepos` | array | — | Required when enabled. Each entry is `{ repo, sensitivity }`; `repo` MUST be a bare `owner/repo` slug and MUST be unique case-insensitively. There is no legacy bare-string form. |
-| `runtime` | `docker` \| `gvisor` \| `sbx` | `docker` | `docker` and `gvisor` are implemented; `sbx` is capability-blocked (§15.7). |
-| `engine` | `copilot` \| `claude` \| `codex` \| `gemini` | `copilot` | Native enclave agent. `copilot` is preinstalled in the standard bounded-agent image; the other accepted values fail closed in preflight until their adapters land. Required when enabled. |
-| `profile` | `openai` \| `anthropic` | `openai` | Legacy provider-loop compatibility field. Native engines select a fixed API-proxy route from `engine`; callers cannot override it. |
-| `model` | string | — | Required when enabled. A request can never choose or override it. |
-| `timeout` | integer (1–540) | `120` | Wall-clock bound for one enclave invocation. Capped so the 10-minute response bucket reserves its final minute for termination, validation, and cleanup. |
-| `memoryLimit` | string | `"512m"` | Docker memory limit; swap disabled at the same value. |
-| `cpuLimit` | string | `"1"` | Docker `--cpus`. |
-| `pidsLimit` | integer | `128` | Docker `--pids-limit`. |
-| `tmpfsLimit` | string | `"64m"` | Size bound for each writable tmpfs (`/tmp` and the `/agent` work/result root). |
-| `maxOutputBytes` | integer (1–8192) | `8192` | Exact size bound on the dedicated result file. |
-| `maxTaskBytes` | integer (1–65536) | `4096` | Byte bound on the caller-supplied task text. |
-| `maxInvocations` | integer | `8` | Per-run response cap; every response, including a rejection, counts. |
-| `maxModelRequests` | integer (1–64) | `8` | Legacy provider-loop compatibility field. The native Copilot CLI does not expose a request-count control, so this is not an enforcement boundary for `engine: "copilot"`. |
-| `maxModelTokens` | integer (1–32768) | `1024` | Legacy provider-loop compatibility field. The native Copilot CLI does not expose a per-call token control, so this is not an enforcement boundary for `engine: "copilot"`. |
-
-Every default is deliberately conservative: a bounded agent is a *model*
-reading confidential source, so the safe posture is a small, short-lived,
-low-token enclave that an operator must explicitly widen.
-
-Bounded agents additionally REQUIRE, at preflight:
-
-- the AWF API proxy to be enabled — the enclave holds no credentials and the
- API proxy is its only permitted upstream egress;
-- a supported configured API target for the selected `engine` (`copilot`
- requires a Copilot GitHub-token or BYOK route);
-- a staging credential in `GH_TOKEN`/`GITHUB_TOKEN` on the AWF host;
-- a Unix-socket Docker host;
-- the **primary agent** runtime to be proven available — `docker`,
- `runsc` registration for `gvisor`, or a proven ingress path (Unix
- passthrough or authenticated `sbx-http`) for a primary `sbx` runtime. There
- is no blanket rejection of a primary microVM runtime; availability is
- proven independently for each run (§15.7.1);
-- the selected **bounded-agent enclave** `runtime` to be proven available;
-- `enableDind` to be disabled, because primary-agent access to the enclave's
- Docker daemon would bypass every finite-disclosure boundary. This holds
- regardless of primary or enclave backend — there is no runtime combination
- in which exposing that socket to the primary agent is safe.
-
-Any failure aborts the run before the primary agent starts.
-
-### 15.3 Request/Result Protocol
-
-A bounded-agent request selects exactly three things:
-
-| Field | Meaning |
-|-------|---------|
-| `privateRepo` | One configured repository, by `owner/repo` slug. |
-| `schema` | A finite response schema, using the same algebra as §14.3. |
-| `task` | Byte-bounded task text, forwarded verbatim into the enclave prompt. |
-
-The `task` is byte-bounded *input*, never configuration: it cannot add a tool,
-change the model, reach an endpoint, or alter any limit.
-
-Everything else is fixed trusted configuration and MUST be rejected if it
-appears in a request — image, command, executable, mount, path, environment,
-endpoint, network, proxy, credential, timeout, resource limit, runtime, or
-tool definition — as MUST any unknown key. Rejecting explicitly named controls
-in addition to the generic unknown-key rule is redundant by construction; it
-is retained so an accidental future widening of the accepted key set fails a
-test rather than silently granting a capability.
-
-The canonical success/error envelopes, the finite schema algebra, the
-information charge (`1` status bit + `ceil(log2(cardinality))` + `3` timing
-bits), the six fixed timing buckets, strict JSON parsing, and canonical
-re-serialization are all the shared bounded-execution primitives introduced
-for bounded queries (§14.3–§14.5) and are reused unchanged.
-
-The charge is debited from the repository's run balance **before** any
-workspace is materialized or any container is created, and is never refunded.
-
-### 15.4 Trusted Host Lifecycle
-
-Identical in shape to §14.6, against a disjoint private root:
-
-1. **Preflight before staging.** Configuration validation and the enclave
- runtime capability proof run first, so a run that could never launch an
- enclave never clones a private repository.
-2. **Sanitized seeds.** One immutable seed per configured repository is cloned
- with the staging credential, scrubbed of remotes, credential helpers,
- hooks, alternates, worktree links, and reflogs, rejected outright if it
- declares submodules, made read-only, and verified read-only.
-3. **Credential scrub before launch.** The `GIT_ASKPASS` helper, the 0600
- token file, and the isolated staging `HOME` are removed before any
- container exists. The credential never appears in argv, a URL, a log line,
- the compose file, or any container environment.
-4. **Protected directories and audit.** Seeds, per-invocation workspaces,
- control state, and the audit log live under a `0700` broker-private root at
- `/var/tmp/awf-bounded-agent-private--`, which is asserted not
- to alias, contain, or be contained by any path visible to a primary agent
- in any supported backend. Only a separate ingress root (broker socket +
- generated `SKILL.md`/wrapper) is mounted into the agent.
-5. **Deterministic orphan cleanup.** Every enclave carries
- `awf.bounded-agent.run=`; teardown force-removes every container with
- that label, including under `--keep-containers`, and the broker reconciles
- the same label at startup and shutdown.
-
-### 15.5 Enclave Execution
-
-For each accepted request the broker launches a fresh, uniquely named,
-labelled container with a frozen argument vector:
-
-- `--network ` — the enclave joins **only** the
- dedicated network (§15.6);
-- `--read-only` root filesystem, with the immutable seed bind-mounted `ro` at
- `/awf/seed` (there is no writable copy of private source anywhere);
-- the caller's task and schema bind-mounted `ro`; the result file bind-mounted
- `rw`;
-- bounded `--tmpfs` mounts for `/tmp` and the `/agent` work/result root;
-- fixed non-root uid/gid `65534:65534`, `--cap-drop ALL`,
- `--security-opt no-new-privileges:true`, a seccomp profile, and
- memory/memory-swap/CPU/PID/`RLIMIT_FSIZE`/`RLIMIT_NOFILE` bounds plus the
- wall-clock timeout;
-- `--pull never`.
-
-Cleanup runs before the response: the container is force-removed and the
-workspace destroyed, and only then is the timing bucket selected.
-
-The result MUST be a single JSON value in the dedicated bounded result file,
-of at most `maxOutputBytes`. The broker reads it with `O_NOFOLLOW` plus an
-explicit regular-file check, rejects invalid UTF-8, validates it strictly
-against the declared schema, and canonically re-serializes it before
-returning. Enclave stdout and stderr are captured only so the child cannot
-block on a full pipe, and are then discarded.
-
-The protected audit log never records the task, the repository name, the
-transcript, the raw result, host paths, tokens, or provider payloads — only an
-invocation id, the trusted sensitivity class, the charge, the timing bucket,
-and a failure category.
-
-### 15.6 Network Topology
-
-Bounded agents introduce one dedicated Docker network, `awf-bounded-agent`,
-declared `internal: true` with an explicit `name:` (the broker launches
-enclaves with a fixed `docker run --network ` argument and must not have
-to derive a Compose project prefix at runtime).
-
-- The **enclave** is a member of that network and of nothing else. It is not on
- `awf-net` or `awf-ext`, has no Squid route and no general proxy, and cannot
- reach the primary agent, the broker, the safe-outputs collector, the MCP
- gateway, or the CLI proxy.
-- A **dedicated API-proxy instance** joins `awf-bounded-agent` at a fixed
- address/alias and a separate egress bridge that no agent can join. It is the
- enclave's only upstream egress and the sole holder of a real provider
- credential. Its token logs, metrics, and quota state live under the
- bounded-agent private root, so enclave request metadata cannot form a side
- channel through the primary agent's API-proxy telemetry.
-- The **broker** runs with `network_mode: none` and never joins the enclave
- network. It receives the Docker socket only because it launches enclaves;
- that path never enters the agent's environment or volumes. When the
- runtime-backend proof requires it (a primary `sbx` runtime unable to prove a
- direct Unix-socket passthrough, §15.7.1), the broker instead exposes a
- dedicated ingress network with one ephemeral port published only on the
- Docker host-gateway address, gated by a random, single-run capability token
- proven reachable before the primary agent starts; the broker itself never
- joins the enclave's `awf-bounded-agent` network either way.
-
-### 15.7 Runtime Backends
-
-`docker` uses the daemon's default OCI runtime. `gvisor` requires the `runsc`
-OCI runtime to be registered with the daemon; availability is proven exactly at
-preflight and again at broker startup, and an unavailable `runsc` NEVER
-downgrades to the default runtime.
-
-`sbx` is accepted by the JSON Schema but is **capability-blocked**: AWF ships a
-dedicated bounded-agent sbx capability probe (host-side
-`src/bounded-agent/sbx-capability.ts`, container-side
-`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the
-exact audited Docker Sandboxes CLI surface using `sbx version`, authenticated
-non-mutating `sbx ls`, and `create --help` / `exec --help` against the audited
-version (`v0.37.1`). It reports every missing capability in structured JSON —
-never a single collapsed boolean, and never a "not yet implemented"
-placeholder. The blocked runner defines `create`, `exec`, `stop`, and
-`rm --force`, but preflight does not claim to execute that lifecycle.
-
-The bounded-agent enclave's network requirement is strictly harder than a
-bounded query's: it must reach *exactly one* peer (the dedicated API proxy),
-not "no network at all". Current `sbx create` exposes `--cpus`, `--memory`,
-`--name`, `--template`, and read-only same-path mounts, but no enforceable,
-mandatory API-proxy-only network policy (an advisory `HTTP_PROXY` env var is
-not a hard network policy and is never treated as one), no PID limits, no disk
-limits, no per-file size limits, and no pinned, digest-verified AWF
-bounded-agent template/bootstrap. The probe therefore always reports these
-missing and `supported` can never be `true` for the currently audited
-version — an intentional, structural "no false pass" design, not an
-oversight. AWF rejects this runtime before staging or compose assembly, mounts
-neither the Docker socket nor any sbx daemon credential, and the broker's
-`SbxEnclaveRunner.assertAvailable()` throws immediately if ever invoked.
-Support remains blocked until sbx provides enforceable versions of all
-controls and AWF publishes a digest-pinned, standard-library-only enclave
-bootstrap.
-
-#### 15.7.1 Primary-agent and bounded-agent runtime matrix
-
-The primary agent runtime and the bounded-agent enclave runtime are separate,
-independently-proven sandbox decisions — mirroring §14's primary-agent/query
-matrix. `container.containerRuntime` selects the primary agent;
-`boundedAgents.runtime` selects the single-use enclave. The broker never
-reuses the primary agent sandbox; every accepted invocation creates a new
-container with a unique run identity and destroys it before returning. No
-combination ever falls back to a weaker or different backend.
-
-`src/bounded-agent/runtime-matrix.ts` evaluates all nine
-`primaryBackend` × `boundedAgentBackend` combinations independently and
-records `lifecycleClass: 'invocation'`, `capabilityState`, and `category` per
-cell for telemetry — never the task, repository name, provider payload, or
-capability token.
-
-| Primary agent | Docker enclave | gVisor enclave | sbx enclave |
-|---|---|---|---|
-| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes |
-| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes |
-| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes |
-
-Six of the nine cells are supported once the relevant runtime(s) are proven
-available; the three `sbx`-enclave cells are not, and remain blocked until the
-capability proof in §15.7 can report `supported: true` for an audited sbx
-version. An unavailable primary runtime fails at primary preflight, before any
-repository is staged; an unavailable enclave runtime fails at enclave
-preflight, for the same reason.
-
-`scripts/ci/report-bounded-agent-runtime-matrix.js` renders this matrix from
-live host probes for CI/local use and reports an explicit `BLOCKED` result —
-exiting non-zero under `--require /` — rather than a
-false pass when no real sbx binary is present.
-
-### 15.8 Agent Interface
-
-When enabled, AWF generates two agent-visible artifacts in the ingress root:
-
-- a `bounded-agent` CLI (installed at `/tmp/awf-lib/bounded-agent`, added to
- `PATH` by the agent entrypoint), and
-- a read-only `SKILL.md` installed under `~/.github/skills/bounded-agent/`.
-
-The CLI accepts exactly `--repo owner/repo`, `--schema ''`, and the task
-text on stdin. It always prints exactly one line of canonical JSON, writes
-nothing to stderr, and exits `0` — for every outcome and every failure.
-
-`boundedAgents.engine` selects the native coding-agent adapter in the standard
-bounded-agent image. `copilot` is preinstalled; `claude`, `codex`, and `gemini`
-are schema-recognized but fail closed in preflight until their pinned adapters
-land. Copilot runs the native CLI with built-in shell/Bash tools. The immutable
-seed remains read-only, writable state is confined to bounded tmpfs mounts, and
-the only network peer is the dedicated API proxy. No credential, host state,
-safe outputs, or GitHub MCP is available inside the enclave.
-
-### 15.9 Provider Disclosure Caveat
-
-A bounded agent necessarily sends repository-derived content — file listings,
-file excerpts, and search hits selected by the model — to the configured model
-provider through the AWF API proxy. **The information-budget ledger bounds what
-the *calling agent* learns, not what the *provider* sees.**
-
-This is a materially different exposure from a bounded query, whose Python
-sandbox has no network at all. Operators MUST treat the configured provider as
-an authorized recipient of repository contents before enabling bounded agents
-for a repository, and SHOULD prefer bounded queries when a deterministic script
-can answer the question.
-
-### 15.10 Residual Channels and Limits
-
-- Disclosure to the calling agent is bounded by the declared schema's charge
- plus the status and timing channels, debited before any workspace or
- container exists; `public` repositories are schema/operationally bounded but
- not bit-metered.
-- `maxInvocations` counts every response, including rejections.
-- Response timing is bucketed to one of six fixed boundaries and charged;
- container and workspace cleanup complete before the bucket is selected.
-- Model requests per invocation, completion tokens per request, task bytes, and
- result bytes are all separately bounded, but a model that is told to encode
- data in its final answer is still limited only by the declared schema's
- cardinality — which is exactly what the ledger charges for.
-- Provider-side exposure is out of scope for the ledger (§15.9).
-- The enclave shares the audited no-network sandbox seccomp profile with
- bounded queries; unlike a bounded query it does have a network interface, to
- the API proxy only.
-
-## 16. Unified Enclaves
-
-The optional `enclaves` object is the successor configuration model for bounded
-private-repository execution. One AWF-owned, no-egress MCP service exposes the
-enabled executors: the script executor launches hardened single-use script
-containers with no network, and the agent executor launches hardened single-use
-enclaves that run a fixed, AWF-authored model loop on a dedicated
-API-proxy-only network. The service is reachable exclusively through a
-compiler-launched, run-labelled `gh-aw-mcpg` gateway on an AWF-owned private
-control network. See
-[Unified Enclave Architecture and Migration](enclaves-architecture.md).
-
-`enclaves.privateRepos` is the single trusted repository list for every
-executor. Each entry has the same `public`, `internal`, `confidential`, or
-`sealed` sensitivity policy used by the legacy systems. The resulting
-information budget is one per-repository, per-run balance shared by script and
-agent executor invocations; an executor change never resets the balance.
-
-`enclaves.executors.script` and `enclaves.executors.agent` are independently
-enabled trusted definitions. Script defaults preserve the bounded-query limits
-(`docker`, no network, `python3`, 30 seconds, 512 MiB, 32 invocations). Agent
-defaults preserve the bounded-agent limits (`docker`, API-proxy-only network,
-Copilot/OpenAI profile, 120 seconds, 512 MiB, 8 invocations, 8 model requests,
-1024 completion tokens). Neither executor is enabled by omission.
-
-Both executors are implemented for `docker` and exactly registered
-`gvisor`/`runsc`. The schema reserves `sbx`, but preflight fails closed for
-either executor because the unified launchers have not proved that backend; it
-never downgrades to Docker or gVisor. The agent executor is implemented only for
-`engine: copilot`, which is the sole engine with a published, audited enclave
-image; another engine fails closed rather than falling back.
-
-An enabled agent executor additionally requires `enableApiProxy` and a
-configured provider route for its engine/profile (Copilot token or BYOK route,
-`ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`), a configured `model`, and the absence
-of `enableDind`. All of these are validated before repository staging.
-
-Images, runtimes, interpreters, engines, provider profiles, models, endpoints,
-networks, mounts, tool sets, system prompts, credentials, timeouts, resource
-limits, and operational limits are trusted configuration. The
-`enclave_run_script` MCP tool accepts exactly `privateRepo`, a finite response
-`schema`, and bounded `script` bytes; the `enclave_run_agent` MCP tool accepts
-exactly `privateRepo`, a finite response `schema`, and a bounded `prompt`. Both
-reject trusted controls, unknown aliases for them, and the other tool's payload
-key.
-
-When `enclaves.enabled` is `true`, at least one executor and one repository are
-required. `boundedQueries.enabled` or `boundedAgents.enabled` MUST NOT also be
-true. AWF rejects that mixed configuration before any legacy broker, enclave
-server, repository staging, or primary agent starts. Disabled sections may
-coexist because they do not activate a runtime.
-
-The AWF-owned MCP server enforces the unified per-repository ledger for both
-executors: a script call and an agent call debit the same live balance, and
-switching executor kinds never resets or forks it. Both executors also share one
-serialization lane inside the server. The HTTP surface admits only one tool call
-to that lane at a time; concurrent calls receive the same canonical error
-without entering a queue. Legacy brokers retain their existing independent
-behavior until runtime cutover.
-
-### 16.1 Agent executor topology and disclosure
-
-Agent enclaves join only the dedicated `internal` `awf-enclave-agent` network
-(172.31.0.0/24). Its only other member is a dedicated API proxy that also joins
-a separate egress bridge and is the only holder of a real provider credential.
-The MCP server is never on that network. It joins only the separate `internal`
-`awf-enclave-mcp-control` network with the externally launched MCP gateway; the
-primary agent, Squid, general API proxy, safe-outputs collector, CLI proxy, and
-all enclave executors are excluded. The server publishes no host port. The
-dedicated agent-enclave proxy's credentials are
-minimized to the configured route, its external telemetry export and Actions
-OIDC token-exchange state are removed, and its logs stay in the enclave-private
-root.
-
-### 16.2 Exclusive MCP gateway handoff
-
-Enclaves require a compiler-generated handoff before staging:
-
-| Variable | Contract |
-|----------|----------|
-| `AWF_ENCLAVE_MCP_CAPABILITY` | Fresh 64-character lowercase hexadecimal bearer capability passed only to mcpg and AWF |
-| `AWF_ENCLAVE_MCP_GATEWAY_CONTAINER` | External gateway container name; `awmg-mcpg` by default |
-| `AWF_ENCLAVE_MCP_GATEWAY_IDENTITY` | Run-unique value equal to the gateway's `com.github.gh-aw.mcpg.run` label |
-| `AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT` | Host-reachable gateway route ending in `/mcp/awf-enclave` |
-| `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` | Optional bounded AWF end-to-end readiness window, 1000-600000 ms; default 120000 |
-
-All five names are unconditionally excluded from primary-agent environment
-passthrough. The mcpg upstream is named `awf-enclave`, uses
-`http://awf-enclave-mcp:8080/mcp`, supplies
-`Authorization: Bearer ${AWF_ENCLAVE_MCP_CAPABILITY}`, allowlists exactly the
-enabled enclave tool names, sets each upstream attempt's `connectTimeout` to 120
-seconds, and sets the per-tool timeout to 630 seconds (the maximum 600-second
-fixed disclosure bucket plus a bounded 30-second gateway allowance).
-`gateway.startupTimeout` is stdio-only and MUST NOT be
-used as the HTTP recovery bound.
-
-The compiler must also enable `network.isolation` and include the configured
-gateway container in `network.topologyAttach`. The enclave server itself is not
-a topology peer: its alias is never added to agent `NO_PROXY`, Squid ACLs, or
-static hosts.
-
-AWF starts Compose infrastructure without the primary agent, attaches only the
-label-matching gateway to `awf-enclave-mcp-control`, verifies the network has
-exactly those two members, then performs `initialize` and `tools/list` through
-the gateway route. The complete static tool contracts must match. Failure,
-timeout, authentication failure, identity mismatch, or tool mismatch aborts
-before Compose starts the agent or sbx creates its sandbox.
-
-This integration requires MCP Gateway specification 1.15.0 and the first mcpg
-release after v0.4.8 containing github/gh-aw-mcpg#10784. Until the upstream is
-available, mcpg returns retryable HTTP 503 `backend_unavailable`; AWF retries
-`initialize` with a bounded 500 ms backoff until
-`AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` expires. AWF does not log the 503 response
-body, request headers, bearer capability, or other secret material. Any other
-HTTP response, malformed recovery response, authentication failure, protocol
-failure, or tool-contract mismatch fails immediately. Every readiness request is
-capped by the remaining AWF readiness budget, so the configured deadline is a
-hard upper bound for the complete handshake.
-
-On shutdown AWF stops the primary-agent work first, sends the server a
-630-second bounded graceful stop covering the maximum fixed disclosure bucket
-plus the stop allowance to close admissions and drain calls, disconnects but does not stop
-the externally owned gateway, then lets Compose remove the AWF-owned control
-network and private service.
-
-Each enclave is single-use: immutable seed mounted read-only, `--read-only`
-root, bounded `tmpfs`, fixed non-root uid/gid, `--cap-drop ALL`,
-`no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds. Every
-enclave container carries `awf.enclave.run` and `awf.enclave.invocation` labels
-so one AWF reconciliation pass removes orphans from either executor.
-
-**Provider disclosure caveat.** Repository-derived content reaches the
-configured model provider through the dedicated API proxy. The ledger bounds
-what the *calling agent* learns, not what the *provider* sees.
+### 14.5 Migration and removed surfaces
+
+The legacy private-repository surfaces are **removed, not deprecated**:
+
+| Removed surface | Replacement |
+| --- | --- |
+| `boundedQueries` | `enclaves.privateRepos` + `enclaves.executors.script` |
+| `boundedAgents` | `enclaves.privateRepos` + `enclaves.executors.agent` |
+| `bounded-query` wrapper / generated skill | `enclave_run_script` |
+| `bounded-agent` wrapper / generated skill | `enclave_run_agent` |
+| Separate legacy ledgers | One shared ledger inside `enclave-mcp-server` |
+| Direct legacy runtime handoffs | Compiler-launched `gh-aw-mcpg` handoff only |
+
+Configuration authors MUST remove the old keys instead of carrying a mixed legacy/unified document.
+
+### 14.6 Validation coverage
+
+Legacy bounded smoke and runtime-matrix workflow assets have been removed from the owned surface. Until a unified gh-aw enclave smoke workflow exists, local coverage remains unit-focused:
+
+- `src/services/enclave-mcp-service.test.ts`
+- `src/services/enclave-agent-service.test.ts`
+- `src/enclave/script-runner-spec.test.ts`
+- `src/enclave/agent-runner-spec.test.ts`
+- `src/enclave/manager.test.ts`
+- `src/enclave/mcp-server.test.ts`
+- `src/enclave/agent-mcp-server.test.ts`
+
+See [Unified Enclave Architecture and Migration](enclaves-architecture.md) for the operator-facing summary.
## Normative References
diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json
index 9f0e138b1..bf0f6dbee 100644
--- a/docs/awf-config.schema.json
+++ b/docs/awf-config.schema.json
@@ -836,282 +836,15 @@
}
}
},
- "boundedQueries": {
- "type": "object",
- "description": "Bounded-query sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `bounded-query` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.",
- "additionalProperties": false,
- "properties": {
- "enabled": {
- "type": "boolean",
- "description": "Enable bounded queries for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host and a Compose-based container runtime. Default: false.",
- "default": false
- },
- "privateRepos": {
- "type": "array",
- "description": "Private repositories the bounded-query broker may run queries against, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches a query). A legacy bare `owner/repo` string is accepted for one release only and normalized to `{ repo, sensitivity: \"internal\" }` with a warning; update it to the object form. Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. AWF stages one immutable seed per entry before the primary agent starts.",
- "items": {
- "oneOf": [
- {
- "type": "string",
- "maxLength": 140,
- "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$"
- },
- {
- "type": "object",
- "additionalProperties": false,
- "required": [
- "repo",
- "sensitivity"
- ],
- "properties": {
- "repo": {
- "type": "string",
- "maxLength": 140,
- "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$"
- },
- "sensitivity": {
- "type": "string",
- "enum": [
- "public",
- "internal",
- "confidential",
- "sealed"
- ],
- "description": "Confidentiality category, which fixes this repository's immutable per-run information budget. Cannot be increased by configuration."
- }
- }
- }
- ]
- },
- "minItems": 1
- },
- "runtime": {
- "type": "string",
- "enum": [
- "docker",
- "gvisor",
- "sbx"
- ],
- "description": "Sandbox runtime backend used to execute the query script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves mandatory no-network, PID, disk, file-size, target-mount, CPU, and memory controls. No backend ever falls back. Default: \"docker\".",
- "default": "docker"
- },
- "timeout": {
- "type": "integer",
- "minimum": 1,
- "maximum": 540,
- "description": "Maximum wall-clock time in seconds allowed for a single query invocation. Exceeding it returns the canonical error. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 30.",
- "default": 30
- },
- "memoryLimit": {
- "type": "string",
- "pattern": "^[1-9][0-9]*[bkmgBKMG]$",
- "description": "Docker-style memory limit applied to the query sandbox (e.g. \"512m\", \"1g\"). Swap is disabled at the same value. Default: \"512m\".",
- "default": "512m"
- },
- "interpreter": {
- "type": "string",
- "enum": [
- "python3"
- ],
- "description": "Script interpreter used to run the query. Only \"python3\" (standard library only, no package installation) is supported.",
- "default": "python3"
- },
- "maxInvocations": {
- "type": "integer",
- "minimum": 1,
- "maximum": 10000,
- "description": "Maximum number of query responses permitted for the current AWF run. Every response — including a rejection — counts, because each one reveals one of the four permitted symbols. Exhaustion returns the canonical ERROR without launching a query. Default: 32.",
- "default": 32
- }
- },
- "if": {
- "properties": {
- "enabled": {
- "const": true
- }
- },
- "required": [
- "enabled"
- ]
- },
- "then": {
- "required": [
- "privateRepos"
- ]
- }
- },
- "boundedAgents": {
- "type": "object",
- "description": "Bounded-agent enclave configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker, and exposes a fixed `bounded-agent` CLI plus a generated skill to the agent. Each invocation runs the configured native coding-agent engine inside a single-use enclave that joins only a dedicated `internal` bounded-agent Docker network whose sole other member is the AWF API proxy. Requires the API proxy and a supported configured engine/model route. See docs/awf-config-spec.md §15.",
- "additionalProperties": false,
- "properties": {
- "enabled": {
- "type": "boolean",
- "description": "Enable bounded agents for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host, an enabled API proxy, and a configured `engine`/`model` route. Default: false.",
- "default": false
- },
- "privateRepos": {
- "type": "array",
- "description": "Private repositories a bounded agent may reason about, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches an enclave). Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. Bounded agents keep a ledger separate from bounded queries.",
- "items": {
- "type": "object",
- "additionalProperties": false,
- "required": [
- "repo",
- "sensitivity"
- ],
- "properties": {
- "repo": {
- "type": "string",
- "maxLength": 140,
- "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$"
- },
- "sensitivity": {
- "type": "string",
- "enum": [
- "public",
- "internal",
- "confidential",
- "sealed"
- ],
- "description": "Confidentiality category, which fixes this repository's immutable per-run information budget. Cannot be increased by configuration."
- }
- }
- },
- "minItems": 1
- },
- "runtime": {
- "type": "string",
- "enum": [
- "docker",
- "gvisor",
- "sbx"
- ],
- "description": "Sandbox runtime backend used to execute the bounded-agent enclave, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves the mandatory API-proxy-only network policy, read-only targeted mounts, unprivileged exec/workdir, and pids/disk/fsize/lifecycle controls this enclave requires. No backend ever falls back. Default: \"docker\".",
- "default": "docker"
- },
- "engine": {
- "type": "string",
- "enum": [
- "copilot",
- "claude",
- "codex",
- "gemini"
- ],
- "description": "Native coding-agent runtime executed inside each single-use enclave. \"copilot\" is implemented with a dedicated pinned Copilot CLI image. Other accepted engine names fail closed until their dedicated images are implemented. The caller cannot choose or override this value.",
- "default": "copilot"
- },
- "profile": {
- "type": "string",
- "enum": [
- "openai",
- "anthropic"
- ],
- "description": "Legacy provider-loop compatibility field. Native engines select their fixed API-proxy route from `engine`; callers cannot override it. Default: \"openai\".",
- "default": "openai"
- },
- "model": {
- "type": "string",
- "minLength": 1,
- "maxLength": 200,
- "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$",
- "description": "Model identifier sent on every enclave request. Required when enabled. A request can never choose or override it."
- },
- "timeout": {
- "type": "integer",
- "minimum": 1,
- "maximum": 540,
- "description": "Maximum wall-clock time in seconds allowed for a single enclave invocation. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 120.",
- "default": 120
- },
- "memoryLimit": {
- "type": "string",
- "pattern": "^[1-9][0-9]*[bkmgBKMG]$",
- "description": "Docker-style memory limit applied to the enclave (e.g. \"512m\"). Swap is disabled at the same value. Default: \"512m\".",
- "default": "512m"
- },
- "cpuLimit": {
- "type": "string",
- "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$",
- "description": "Fractional CPU limit applied to the enclave (Docker --cpus). Default: \"1\".",
- "default": "1"
- },
- "pidsLimit": {
- "type": "integer",
- "minimum": 1,
- "maximum": 4096,
- "description": "Maximum number of processes/threads the enclave may create. Default: 128.",
- "default": 128
- },
- "tmpfsLimit": {
- "type": "string",
- "pattern": "^[1-9][0-9]*[bkmgBKMG]$",
- "description": "Docker-style size limit for each of the enclave's writable tmpfs mounts (/tmp and the /agent work/result root). Default: \"64m\".",
- "default": "64m"
- },
- "maxOutputBytes": {
- "type": "integer",
- "minimum": 1,
- "maximum": 8192,
- "description": "Maximum size in bytes of the enclave's dedicated result file. The broker reads back at most this many bytes and requires exactly one JSON value conforming to the declared finite schema. Default: 8192.",
- "default": 8192
- },
- "maxTaskBytes": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65536,
- "description": "Maximum size in bytes of the caller-supplied bounded task text. The task is byte-bounded input forwarded verbatim into the enclave prompt; it is never interpreted as configuration. Default: 4096.",
- "default": 4096
- },
- "maxInvocations": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "description": "Maximum number of enclave responses permitted for the current AWF run. Every response — including a rejection — counts. Exhaustion returns the canonical error without launching an enclave. Default: 8.",
- "default": 8
- },
- "maxModelRequests": {
- "type": "integer",
- "minimum": 1,
- "maximum": 64,
- "description": "Maximum number of model requests one enclave invocation may issue through the API proxy. Default: 8.",
- "default": 8
- },
- "maxModelTokens": {
- "type": "integer",
- "minimum": 1,
- "maximum": 32768,
- "description": "Maximum completion tokens requested per model call (max_tokens). Default: 1024.",
- "default": 1024
- }
- },
- "if": {
- "properties": {
- "enabled": {
- "const": true
- }
- },
- "required": [
- "enabled"
- ]
- },
- "then": {
- "required": [
- "privateRepos",
- "engine",
- "model"
- ]
- }
- },
"enclaves": {
"type": "object",
- "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes enabled executors only through an AWF-owned MCP server and the compiler-launched trusted mcpg gateway.",
+ "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, every invocation debits one live per-repository information budget regardless of executor kind, and AWF exposes enabled executors only through an AWF-owned MCP server plus the compiler-launched trusted mcpg gateway.",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false,
- "description": "Enable the unified enclave subsystem. Cannot be enabled with boundedQueries or boundedAgents."
+ "description": "Enable the unified enclave subsystem. Requires at least one private repository and at least one enabled executor."
},
"privateRepos": {
"type": "array",
@@ -1320,18 +1053,6 @@
"minimum": 1,
"maximum": 1000,
"default": 8
- },
- "maxModelRequests": {
- "type": "integer",
- "minimum": 1,
- "maximum": 64,
- "default": 8
- },
- "maxModelTokens": {
- "type": "integer",
- "minimum": 1,
- "maximum": 32768,
- "default": 1024
}
},
"if": {
diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md
deleted file mode 100644
index 7693b5ee1..000000000
--- a/docs/bounded-agents.md
+++ /dev/null
@@ -1,402 +0,0 @@
-# Bounded Agents
-
-Delegate narrow, brokered *agentic* tasks about private repositories to an
-isolated enclave whose only reachable peer is the AWF API proxy.
-
-A **bounded agent** lets an agent hand a trusted broker a bounded task about one
-pre-approved private repository and get back a single value conforming to a
-finite schema it declares up front — without ever seeing repository contents,
-the enclave's transcript, its tool calls, its diagnostics, or its exit status.
-
-Bounded agents are the agentic sibling of [bounded queries](bounded-queries.md).
-A bounded query runs an agent-authored Python script in a sandbox with **no
-network at all**. A bounded agent runs a pinned native coding-agent CLI in an
-engine-specific enclave that can reach exactly one thing: the AWF API proxy.
-
-The feature is config-only: there are no `--bounded-agents-*` CLI flags.
-Everything is expressed in the AWF JSON configuration file.
-
-## When to use which
-
-| | Bounded query | Bounded agent |
-|---|---|---|
-| Work is | an agent-authored Python script | a configured native coding-agent CLI |
-| Enclave network | none | the API proxy, and nothing else |
-| Repository access | writable private copy | read-only immutable seed |
-| Provider sees repo content | never | **yes** — see [Provider disclosure](#provider-disclosure) |
-| Good for | deterministic, scriptable questions | questions needing judgment or multi-step reading |
-
-**Prefer a bounded query whenever a deterministic script can answer the
-question.** Reach for a bounded agent only when the question genuinely needs a
-model to read and judge.
-
-**Good uses**
-
-- "Which of these four architectural patterns best describes the service?" — small enum
-- "Does the error-handling in this module meet our documented standard?" — boolean
-- "Which subsystem owns retry policy: `net`, `store`, `api`, or none?" — small enum
-
-**Not suited for**
-
-- Extracting source code, documentation, or any variable-length text — unbounded strings are structurally impossible in the schema DSL
-- Anything a bounded query can answer deterministically
-- Repositories whose contents must never reach the configured model provider
-- Repositories marked `sealed` (0-bit budget) — these can never fund a single invocation
-
-## Architecture
-
-Four trust stages, mirroring bounded queries:
-
-1. **Trusted host preflight, then staging.** Preflight runs *first*: AWF
- validates the configuration, rejects Docker-socket exposure to the primary
- agent, requires the API proxy plus a configured `engine`/`model` route, and
- proves the enclave runtime is available. Only
- then does it clone each configured repository using `GH_TOKEN`/`GITHUB_TOKEN`,
- strip all credentials, remotes, hooks, and write bits, and reject submodules
- and gitdir pointers. The staging credential is scrubbed before any container
- exists. A run that could never launch an enclave never clones anything.
-
-2. **Trusted broker over a private ingress.** A dedicated `awf-bounded-agent-broker`
- container with `network_mode: none` serves requests over a Unix socket
- mounted into the agent. It has no network at all — not even the enclave
- network it launches enclaves onto. It holds the seed map (including each
- repository's trusted sensitivity), which the agent can never read or modify,
- and it keeps a ledger **separate** from bounded queries. When the primary
- agent itself runs under `containerRuntime: "sbx"`, AWF first probes whether
- the microVM's filesystem passthrough can bind the broker's Unix socket
- directly; when it cannot, the broker instead listens on a dedicated Docker
- `internal` network with one ephemeral port published only on the Docker
- host-gateway address, and the agent is given only the endpoint plus a
- random, single-run capability token proven reachable before the agent
- starts (see [Primary-agent and bounded-agent runtime
- matrix](#primary-agent-and-bounded-agent-runtime-matrix)).
-
-3. **Single-use enclave on an API-proxy-only network.** For each accepted
- request the broker launches one fresh, uniquely named, labelled container
- with a frozen argument vector: read-only root, the immutable seed
- bind-mounted read-only, bounded tmpfs mounts for work/result/`/tmp`, fixed
- non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile,
- and memory/CPU/PID/file-size/timeout bounds. It joins only the dedicated
- `internal` `awf-bounded-agent` network, whose sole other member is a
- dedicated API proxy with private telemetry and a separate egress bridge.
-
-4. **Canonical finite result and cleanup.** The enclave writes exactly one JSON
- value to a dedicated bounded result file. The broker force-removes the
- container, destroys the workspace, validates the result against the declared
- schema, canonically re-serializes it, and only then selects the timing
- bucket. The agent receives exactly `{"status":"ok","result":}` or
- `{"status":"error"}` — and never the remaining budget.
-
-## Configuration
-
-```json
-{
- "apiProxy": { "targets": { "copilot": {} } },
- "boundedAgents": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/private-service", "sensitivity": "internal" }
- ],
- "runtime": "docker",
- "engine": "copilot",
- "model": "gpt-4o-mini",
- "timeout": 120,
- "memoryLimit": "512m",
- "cpuLimit": "1",
- "pidsLimit": 128,
- "tmpfsLimit": "64m",
- "maxOutputBytes": 8192,
- "maxTaskBytes": 4096,
- "maxInvocations": 8
- }
-}
-```
-
-| Field | Default | Meaning |
-|-------|---------|---------|
-| `enabled` | `false` | Only an explicit `true` enables the subsystem. |
-| `privateRepos` | — | Required when enabled. `{ repo, sensitivity }` entries; `repo` must be a bare `owner/repo` slug, unique case-insensitively. |
-| `runtime` | `docker` | `docker` or `gvisor`. `sbx` is accepted by the schema but remains capability-blocked (see below). |
-| `engine` | `copilot` | Native enclave agent. `copilot` is preinstalled in the standard bounded-agent image; `claude`, `codex`, and `gemini` fail closed until their adapters land. Required when enabled. |
-| `profile` | `openai` | Legacy provider-loop compatibility field. Native engines select their fixed API-proxy route from `engine`; callers cannot override it. |
-| `model` | — | Required when enabled. A request can never choose or override it. |
-| `timeout` | `120` | Wall-clock seconds for one invocation (max 540). |
-| `memoryLimit` | `"512m"` | Docker memory limit; swap disabled at the same value. |
-| `cpuLimit` | `"1"` | Docker `--cpus`. |
-| `pidsLimit` | `128` | Docker `--pids-limit`. |
-| `tmpfsLimit` | `"64m"` | Size bound for each writable tmpfs. |
-| `maxOutputBytes` | `8192` | Exact size bound on the result file. |
-| `maxTaskBytes` | `4096` | Byte bound on the task text. |
-| `maxInvocations` | `8` | Per-run response cap; rejections count. |
-| `maxModelRequests` | `8` | Legacy provider-loop compatibility field; the native Copilot engine does not expose a request-count control, so this is not an enforcement boundary for `engine: "copilot"`. |
-| `maxModelTokens` | `1024` | Legacy provider-loop compatibility field; the native Copilot engine does not expose a per-call token control, so this is not an enforcement boundary for `engine: "copilot"`. |
-
-Every default is deliberately conservative. Widen them explicitly, and only as
-far as a task actually needs.
-
-### Requirements
-
-Bounded agents abort the run at preflight — before staging clones anything —
-unless all of the following hold:
-
-- the AWF API proxy is enabled (the enclave holds no credentials, and the proxy
- is its only permitted upstream egress);
-- the selected `engine` has a configured API target (`copilot` requires a
- Copilot GitHub-token or BYOK route);
-- `model` is set;
-- a staging credential is present in `GH_TOKEN` or `GITHUB_TOKEN`;
-- the Docker host is a Unix socket;
-- the **primary agent** runtime is actually available (`docker`, `runsc`
- registration for `gvisor`, or a proven sbx ingress path for `sbx` — see
- [Primary-agent and bounded-agent runtime
- matrix](#primary-agent-and-bounded-agent-runtime-matrix)); a blanket
- rejection of a primary `sbx` runtime is no longer applied — availability is
- proven, not assumed;
-- the selected **bounded-agent enclave** runtime is actually available.
-
-## Docker and gVisor
-
-`runtime: "docker"` uses the daemon's default OCI runtime.
-
-`runtime: "gvisor"` requires the `runsc` OCI runtime to be registered with the
-Docker daemon. Availability is proven exactly at preflight and again at broker
-startup. **An unavailable `runsc` never downgrades to the default runtime** — the
-run aborts instead.
-
-```json
-{ "boundedAgents": { "enabled": true, "runtime": "gvisor", "engine": "copilot", "model": "gpt-4o-mini",
- "privateRepos": [{ "repo": "my-org/private-service", "sensitivity": "internal" }] } }
-```
-
-Nothing else about the topology, mounts, budgets, or protocol changes between
-the two backends.
-
-## `sbx` capability-blocked
-
-`runtime: "sbx"` is accepted by the JSON Schema so configurations can be
-written ahead of support landing, but it is **capability-blocked** — never a
-blanket "not yet implemented" refusal, and never a false pass. AWF ships a
-dedicated bounded-agent sbx capability probe
-(`src/bounded-agent/sbx-capability.ts`, mirrored in
-`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the
-exact audited Docker Sandboxes CLI (`v0.37.1`) surface with `sbx version`, the
-authenticated non-mutating `sbx ls`, and `create --help` / `exec --help`.
-Lifecycle commands belong to the blocked runner and are not claimed as an
-executed proof. The probe reports every missing capability in structured JSON
-rather than a single boolean.
-
-The enclave requirement is strictly harder than a bounded query's: an
-enclave must reach *exactly one* peer (the dedicated API proxy), not "no
-network at all". Current `sbx create` supports `--cpus`, `--memory`, `--name`,
-`--template`, and read-only same-path mounts, but does **not** expose the hard
-controls AWF requires for a mandatory, enforceable API-proxy-only network
-policy (not an advisory `HTTP_PROXY`), PID limits, disk limits, per-file size
-limits, or a pinned, digest-verified AWF bounded-agent template/bootstrap.
-The probe therefore always reports these as missing and `supported` can never
-be `true` for the currently audited version — by design, not by omission.
-
-AWF rejects this runtime before staging or compose assembly, mounts neither
-the Docker socket nor any sbx daemon credential, and the broker's
-`SbxEnclaveRunner` throws immediately if ever invoked. Support remains blocked
-until sbx provides enforceable versions of all controls and AWF publishes a
-digest-pinned, standard-library-only bootstrap for the enclave — the same
-promotion bar as bounded queries.
-
-## Primary-agent and bounded-agent runtime matrix
-
-The primary agent and the bounded-agent enclave are separate sandbox
-decisions, each with its own availability proof:
-
-- `container.containerRuntime` / `--container-runtime` selects the **primary
- agent** runtime.
-- `boundedAgents.runtime` selects the **single-use enclave** runtime.
-
-The broker never reuses the primary agent sandbox. Every accepted invocation
-creates a new container with a unique run identity and destroys it before
-returning. No combination ever falls back to a weaker or different backend.
-
-| Primary agent | Docker enclave | gVisor enclave | sbx enclave |
-|---|---|---|---|
-| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes |
-| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes |
-| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes |
-
-"Supported" is capability-dependent, not an instruction to downgrade. An
-unavailable primary runtime fails at primary preflight, before any repository
-is staged. An unavailable enclave runtime fails at enclave preflight, for the
-same reason. Selecting `"runtime": "sbx"` for the enclave is an explicit,
-still-experimental gate; the additional executable capability proof must also
-pass. With Docker Sandboxes `v0.37.1`, all three sbx-enclave cells remain
-blocked — six of the nine combinations are supported once the relevant
-runtime(s) are proven available, and the three `sbx`-enclave cells are not.
-
-`src/bounded-agent/runtime-matrix.ts` evaluates all nine combinations
-independently (`primaryBackend` × `boundedAgentBackend`) and records
-`lifecycleClass`, `capabilityState`, and `category` per cell for telemetry —
-never the task, repository name, or provider payload.
-`scripts/ci/report-bounded-agent-runtime-matrix.js` renders the same matrix
-from live host probes for CI/local use; it reports an explicit `BLOCKED`
-result (and exits non-zero under `--require`) rather than a false pass when no
-real sbx binary is present.
-
-Examples of independent selection:
-
-```json
-{ "container": { "containerRuntime": "sbx" },
- "boundedAgents": { "enabled": true, "runtime": "docker", "engine": "copilot", "model": "gpt-4o-mini",
- "privateRepos": [{ "repo": "my-org/private-service", "sensitivity": "internal" }] } }
-```
-
-### Troubleshooting runtime selection
-
-| Symptom | Meaning | Action |
-|---|---|---|
-| `runsc ... not available; no fallback` | The gVisor enclave backend is not registered with Docker | Register `runsc`, verify it appears in `docker info --format '{{json .Runtimes}}'`, and rerun |
-| `sbx ... blocked ... mandatory` capability error | The sbx enclave capability probe failed as designed | Read the complete missing-capability list; do not substitute local policy or a weaker runtime |
-| sbx primary ingress probe fails | The primary VM cannot reach the broker through either proven ingress | Verify sbx Unix passthrough or authenticated `sbx-http` ingress; the agent must not start |
-| Docker host must be `unix://` | The networkless broker cannot reach a TCP daemon | Use a local Unix socket; AWF will not attach the broker to a network |
-| `bounded agents cannot be combined with enableDind` | Docker-socket exposure to the primary agent would bypass every finite-disclosure boundary | Disable `enableDind`; there is no runtime combination in which this is safe |
-| Matrix report says `BLOCKED` | Capability or security preflight prevented launch | Treat this as expected fail-closed status, not successful runtime execution |
-
-Run `node scripts/ci/report-bounded-agent-runtime-matrix.js` after `npm run
-build` to print all nine local capability results. Use `--require
-docker/docker` (or another pair) when a smoke job must require one executable
-combination.
-
-### sbx enclave promotion criteria
-
-The experimental sbx enclave backend MUST remain blocked until all of these are
-demonstrated in real VMs, not only deterministic fakes:
-
-1. A digest-pinned AWF bounded-agent template/bootstrap exists.
-2. A mandatory, enforceable API-proxy-only network policy is available and
- enforced by the sbx runtime itself — not an advisory `HTTP_PROXY` env var,
- and not organization-level network policy that can be replaced.
-3. CPU, memory, PID, aggregate disk, and per-file size limits are enforceable.
-4. Read-only seed/task/schema/result mounts have explicit guest targets and
- expose no broker state, credentials, sibling repository, or prior
- invocation.
-5. Timeout, OOM, PID, disk, file-size, malformed/oversized output, and
- interruption cleanup tests all pass.
-6. Unix and authenticated `sbx-http` primary ingress retain byte-identical
- protocol behavior.
-7. Direct and lateral reachability to anything other than the dedicated API
- proxy is proven denied, not merely unconfigured.
-
-Passing a version check alone, or passing only the CLI help probe, is not
-enough to promote the backend.
-
-The locally available `docker sandbox` plugin (`v0.12.0`) was also inspected
-through its executable `create shell`, `exec`, `network proxy`, `stop`, and
-`rm` interfaces. It offers same-path workspace mounts and host/CIDR proxy
-policy, but no explicit guest mount targets, create-time CPU/memory/PID/disk/
-file-size bounds, or digest-enforced AWF bootstrap contract. Those are
-mandatory controls, so this older interface is reported as capability-blocked;
-its help text is not treated as execution evidence.
-
-## Agent interface
-
-When enabled, the agent gets a `bounded-agent` CLI on its `PATH` and a
-generated `SKILL.md` under `~/.github/skills/bounded-agent/`.
-
-```bash
-bounded-agent \
- --repo my-org/private-service \
- --schema '{"type":"enum","values":["net","store","api","none"]}' \
- <<'TASK'
-Which subsystem owns the retry policy for outbound HTTP calls?
-TASK
-```
-
-The CLI accepts exactly `--repo` (once), `--schema` (once), and the task text on
-stdin. There are no other options: it cannot express an image, command,
-executable, model, provider, profile, tool, system prompt, runtime, timeout,
-mount, path, network, proxy, endpoint, resource limit, environment variable, or
-credential. It always prints exactly one line of canonical JSON, writes nothing
-to stderr, and exits `0`.
-
-Inside a Copilot enclave, the pinned native Copilot CLI gets its built-in tools,
-including shell and Bash. Those tools remain confined by the enclave boundary:
-the immutable repository seed is read-only, `/agent` and `/tmp` are bounded
-tmpfs mounts, the process is non-root with no capabilities, and the only
-reachable network peer is the dedicated API proxy. There is no host state, safe
-outputs, GitHub MCP, or credential in the enclave.
-
-That exclusion is deliberate: authenticated `gh`, safe outputs, the CLI proxy,
-and MCP gateways are authority-bearing interfaces whose output is not covered
-by the finite result schema. `mcpg` may become an enclave implementation detail
-only after it can preserve this fixed authority-free tool set and canonical
-result boundary. Its raw Podman arguments, logs, stdio, and `jq` filters are not
-the result boundary and must never be exposed to the caller.
-
-## Budget
-
-Every invocation reserves a fixed information charge from its repository's run
-budget, computed **before** any workspace or container is created:
-
-```text
-charge = 1 (ok/error) + ceil(log2(schema cardinality)) + 3 (timing)
-```
-
-| Sensitivity | Run budget |
-|-------------|-----------:|
-| `public` | unmetered |
-| `internal` | 64 bits/run |
-| `confidential` | 8 bits/run |
-| `sealed` | 0 bits/run — never launches an enclave |
-
-Charges are never refunded, regardless of outcome. The remaining balance is
-never disclosed to the calling agent.
-
-Bounded agents keep a ledger **separate** from bounded queries: the two
-subsystems run separate brokers with separate seed maps in separate private
-roots, so spending on one never consumes the other's balance.
-
-## Threat model
-
-**What bounded agents defend against**
-
-| Threat | Control |
-|---|---|
-| Calling agent reads private source | It never receives repository bytes — only one canonical envelope. |
-| Calling agent escalates through the request | The request selects only a repository, a finite schema, and bounded task text. Image, command, executable, mount, env, endpoint, network, proxy, credential, timeout, resource, runtime, and tool controls — and unknown keys — are rejected. |
-| Enclave exfiltrates over the network | The enclave joins only an `internal` network whose sole other member is a dedicated API proxy. No Squid, no general proxy, no DNS route out, no internet. |
-| Enclave signals through proxy telemetry | Bounded-agent traffic uses a separate API-proxy process whose logs, metrics, quota counters, and egress network are not reachable or mounted by the primary agent. |
-| Enclave reaches other AWF components | The primary agent, Squid, the broker, safe outputs, the MCP gateway, and the CLI proxy are all off that network. |
-| Enclave steals credentials | It holds none. The API proxy injects the real key; the enclave's environment is a fixed list with no credential, token, or proxy variable. |
-| Enclave mutates or persists private source | The seed is bind-mounted read-only into a read-only root; there is no writable copy on the host. |
-| Enclave escapes the sandbox | Non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds. |
-| Broker is used as a launcher for arbitrary containers | The argument vector is frozen and derived only from trusted config plus broker-generated identifiers. |
-| Failure classes leak signal | Every failure collapses to the identical `{"status":"error"}`; reasons go only to a protected audit log. |
-| Latency leaks signal | Responses are held to one of six fixed timing buckets, chosen only after cleanup, and timing is charged to the budget. |
-| Orphaned enclaves retain private content | Every enclave is labelled with the run id and force-removed at teardown, including under `--keep-containers`. |
-| Staging credential leaks | Used only by the trusted host phase via a `GIT_ASKPASS` helper reading a 0600 file; scrubbed before any container exists; never in argv, a URL, a log, or a compose file. |
-
-**What bounded agents do NOT defend against**
-
-- **Provider exposure.** See below.
-- **Semantic misclassification.** The feature enforces the declared sensitivity
- budget; it cannot validate that an operator classified a repository correctly.
-- **A model that spends its budget badly.** A high-cardinality schema is charged
- accordingly, but the caller still chooses what question to ask.
-
-### Provider disclosure
-
-A bounded agent necessarily sends repository-derived content — file listings,
-file excerpts, and search hits selected by the model — to the configured model
-provider through the AWF API proxy.
-
-**The information-budget ledger bounds what the *calling agent* learns, not what
-the *provider* sees.**
-
-This is a materially different exposure from a bounded query, whose Python
-sandbox has no network at all. Before enabling bounded agents for a repository,
-treat the configured provider as an authorized recipient of that repository's
-contents. When a deterministic script can answer the question, use a
-[bounded query](bounded-queries.md) instead.
-
-## Related
-
-- [Bounded queries](bounded-queries.md) — the no-network, script-based sibling
-- [AWF configuration spec §15](awf-config-spec.md) — normative model
-- [API proxy sidecar](api-proxy-sidecar.md) — credential isolation
diff --git a/docs/bounded-queries.md b/docs/bounded-queries.md
deleted file mode 100644
index f51776ffe..000000000
--- a/docs/bounded-queries.md
+++ /dev/null
@@ -1,425 +0,0 @@
-# Bounded Queries
-
-Run narrow, brokered Python scripts against private repositories without
-exposing repository contents to the primary agent.
-
-A **bounded query** lets an agent ask a trusted broker to run a short, agent-authored Python 3 script against a private repository and get back a single value conforming to a finite schema the agent declares up front -- without the agent ever seeing repository contents, receiving diagnostic output, or gaining network access to the repository.
-
-The feature is config-only: there are no `--bounded-queries-*` CLI flags. Everything is expressed in the AWF JSON configuration file.
-
-## Use cases
-
-Bounded queries are designed for **bounded, answerable questions** about a private repository where the question and its full range of answers can be expressed as a finite schema.
-
-**Good uses**
-
-- "Does this repository contain a `SECURITY.md` at the root?" -- boolean, 1 bit of payload
-- "How many Python files are in `src/`?" -- bounded integer with a known upper limit
-- "Which license identifier is declared: MIT, Apache-2.0, GPL-3.0, or something else?" -- small enum
-- "Is the `requires-python` minimum in `pyproject.toml` at least 3.10?" -- boolean
-- "Do both repositories declare the same major API version in their manifest?" -- each queried separately; answers compared by the agent after two queries
-
-**Not suited for**
-
-- Extracting source code, documentation, or any variable-length text -- unbounded strings are structurally impossible in the schema DSL
-- Arbitrary repository exploration or browsing
-- Tasks where the answer space cannot be described by a finite schema before the query runs
-- Repositories marked `sealed` (0-bit budget) -- these can never fund even the cheapest query
-
-:::note
-Bounded queries bound *quantity* of information revealed, not *semantics*. Classifying a repository's sensitivity level is an operator responsibility; the feature enforces the declared limit but cannot validate that the classification is correct.
-:::
-
-## Architecture
-
-The trust boundary operates in four stages:
-
-1. **Trusted host staging.** Before any container starts, AWF clones each configured repository using `GH_TOKEN`/`GITHUB_TOKEN`, strips all credentials, remotes, hooks, and write bits from the resulting seed, and records the resolved commit in trusted staging metadata. Submodules and gitdir pointers are rejected. The staging credential is scrubbed after this phase and never reaches the broker or agent.
-
-2. **Trusted broker over Unix socket.** A dedicated `awf-bounded-query-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It receives no network, no Squid proxy, and no external bridge. Docker/gVisor query runtimes give it the agent-invisible Docker socket used to launch queries. The blocked sbx preview receives no daemon access. The broker holds the seed map -- including each repository's trusted sensitivity -- which the agent can never read or modify.
-
-3. **Fresh, no-network query sandbox.** For each accepted request the broker creates a private writable copy of exactly one seed, then launches a single-use container with no network, a read-only root filesystem with bounded writable tmpfs mounts at `/tmp` and `/query`, no capabilities, a restrictive seccomp profile, and fixed memory, CPU, PID, and timeout limits. The agent-authored script runs at `/awf/query-script.py` and must write its result to `/query/out`. Stdout, stderr, and exit status are discarded.
-
-4. **Canonical finite result and cleanup.** After the script exits, the broker validates the result file against the declared schema using a non-backtracking hand-written parser, re-serializes the canonical form, tears down the workspace, then -- only after cleanup completes -- selects the timing bucket and responds. The agent receives exactly `{"status":"ok","result":}` or `{"status":"error"}` with nothing else.
-
-## Configuration
-
-Add a `boundedQueries` section to your AWF JSON config file:
-
-```json
-{
- "boundedQueries": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/private-service", "sensitivity": "internal" },
- { "repo": "my-org/public-docs", "sensitivity": "public" }
- ],
- "runtime": "docker",
- "timeout": 30,
- "memoryLimit": "512m",
- "interpreter": "python3",
- "maxInvocations": 32
- }
-}
-```
-
-### Field reference
-
-| Field | Type | Constraints | Default |
-|---|---|---|---|
-| `enabled` | boolean | Only explicit `true` enables the feature; omission normalizes to `false` | `false` |
-| `privateRepos` | array | Required non-empty when `enabled: true`; entries must be unique by slug (case-insensitive) | `[]` |
-| `runtime` | string | `"docker"`, `"gvisor"`, or fail-closed preview `"sbx"` | `"docker"` |
-| `timeout` | integer | `1`-`540` seconds; the final 60 seconds before the 600-second bucket boundary are reserved for termination, validation, and cleanup | `30` |
-| `memoryLimit` | string | Docker memory format, e.g. `"512m"`, `"1g"` | `"512m"` |
-| `interpreter` | string | Only `"python3"` is currently supported | `"python3"` |
-| `maxInvocations` | integer | `1`-`10000`; an independent operational cap unrelated to per-repository bit budgets | `32` |
-
-**`privateRepos` entry format.** Each entry must be an object:
-
-```json
-{ "repo": "owner/repo", "sensitivity": "internal" }
-```
-
-The `sensitivity` value must be `public`, `internal`, `confidential`, or `sealed`. The `repo` value must be a bare `owner/repo` slug with no scheme, host, path traversal, query string, fragment, wildcard, or extra path segments.
-
-**Legacy bare strings.** For one release, a bare `"owner/repo"` string is accepted and normalized to `{ "repo": "...", "sensitivity": "internal" }` with a warning. New configuration should always use the object form so the intended sensitivity is explicit.
-
-**Disabled behavior.** When `enabled` is `false` or the section is absent, AWF stages nothing, starts no broker, mounts no socket, sets no environment variable, installs no CLI, and generates no skill.
-
-**Preflight failures** (all fail before the primary agent starts): `privateRepos` is empty, contains an invalid slug, or has duplicates; `runtime` is `"gvisor"` and `runsc` is not registered; `runtime` is `"sbx"` and its executable capability proof is incomplete; a Docker/gVisor query uses a non-Unix Docker host; `timeout` exceeds 540; no staging credential is present; or any seed cannot be materialized and verified.
-
-### sbx query runtime status
-
-`"runtime": "sbx"` is a fail-closed preview surface. It is independent of the
-primary-agent runtime: selecting it never reuses the primary agent's VM,
-transport capability, or credentials, and it never falls back to Docker or
-gVisor.
-
-The broker contains a dedicated `SbxQueryRunner` and executable
-`sbx-capability-probe.js`. The audited CLI is Docker Sandboxes `v0.37.1`, using
-the exact management surface `sbx version`, `sbx create`, `sbx exec`,
-`sbx ls --json`, `sbx stop`, and `sbx rm --force`. AWF requires a unique
-`awf-query-sbx--` VM, one CPU, the configured memory bound, an
-immutable digest-pinned Python template, read-only seed/script target mounts,
-an unprivileged fixed exec, and deterministic stop/delete scoped to that run.
-
-Current `sbx create` supports `--cpus`, `--memory`, `--name`, `--template`, and
-read-only same-path mounts, but it does **not** expose the hard controls AWF
-needs for `--network=none`, PID limits, disk limits, per-file size limits, or
-explicit guest mount targets. Local and kit network denies are not equivalent:
-organization governance can replace them. AWF therefore rejects this runtime
-before staging or broker assembly and mounts neither the Docker socket nor any
-sbx daemon credential. The probe exits non-zero and reports every missing
-capability in JSON. Support remains blocked until sbx provides enforceable
-versions of all controls and AWF publishes a digest-pinned standard-library-only
-Python template/bootstrap.
-
-### Primary-agent and query runtime matrix
-
-The primary agent and each bounded query are separate sandbox decisions:
-
-- `container.containerRuntime` / `--container-runtime` selects the **primary
- agent** runtime.
-- `boundedQueries.runtime` selects the **single-use query** runtime.
-
-The broker never reuses the primary agent sandbox. Every accepted query creates
-a new container or VM with a unique run/invocation identity and destroys it
-before returning. No combination falls back to a weaker backend.
-
-| Primary agent | Docker query | gVisor query | sbx query |
-|---|---|---|---|
-| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx query probes |
-| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx query probes |
-| sbx | Supported when primary sbx and broker ingress probes pass | Supported when primary sbx, ingress, and `runsc` probes pass | **Blocked** by mandatory sbx query probes |
-
-“Supported” is capability-dependent, not an instruction to downgrade. An
-unavailable primary runtime fails at primary preflight. An unavailable query
-runtime fails at query preflight before the private root is created or any
-repository is staged. Selecting `"runtime": "sbx"` is the explicit experimental
-gate; the additional executable capability proof must also pass. With Docker
-Sandboxes `v0.37.1`, all three sbx-query cells remain blocked.
-
-Examples of independent selection:
-
-```json
-{
- "container": { "containerRuntime": "gvisor" },
- "boundedQueries": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/private-service", "sensitivity": "internal" }
- ],
- "runtime": "docker"
- }
-}
-```
-
-```json
-{
- "container": { "containerRuntime": "sbx" },
- "boundedQueries": {
- "enabled": true,
- "privateRepos": [
- { "repo": "my-org/private-service", "sensitivity": "confidential" }
- ],
- "runtime": "gvisor"
- }
-}
-```
-
-The second example starts only when sbx primary-agent ingress and Docker
-`runsc` query probes both pass.
-
-### Runtime telemetry
-
-AWF emits a deliberately narrow runtime telemetry record. It contains exactly:
-primary backend, query backend, lifecycle class, capability state, and
-success/failure category. It never contains repository identifiers or contents,
-scripts, raw outputs, host/container paths, tokens, ingress capabilities, or
-daemon credentials. Broker records are written to the protected
-`runtime-telemetry.jsonl` file beside the protected audit log and are never
-mounted into the agent.
-
-### Troubleshooting runtime selection
-
-| Symptom | Meaning | Action |
-|---|---|---|
-| `runsc ... not available; no fallback` | The gVisor query backend is not registered with Docker | Register `runsc`, verify it appears in `docker info --format '{{json .Runtimes}}'`, and rerun |
-| `sbx ... blocked ... mandatory query-isolation controls` | The sbx query security probe failed as designed | Read the complete missing-control list; do not substitute local policy or a weaker runtime |
-| sbx primary ingress probe fails | The primary VM cannot reach the broker through either proven ingress | Verify sbx Unix passthrough or authenticated host-loopback ingress; the agent must not start |
-| Docker host must be `unix://` | The networkless broker cannot reach a TCP daemon | Use a local Unix socket; AWF will not attach the broker to a network |
-| Matrix report says `BLOCKED` | Capability or security preflight prevented launch | Treat this as expected fail-closed status, not successful runtime execution |
-
-Run `node scripts/ci/report-bounded-query-runtime-matrix.js` after `npm run
-build` to print all nine local capability results. Use `--require
-docker/docker` (or another pair) when a smoke job must require one executable
-combination.
-
-### sbx query promotion criteria
-
-The experimental sbx query backend MUST remain blocked until all of these are
-demonstrated in real VMs, not only deterministic fakes:
-
-1. A digest-pinned AWF Python standard-library-only template/bootstrap exists.
-2. Per-VM network-none and lateral-connectivity denial are enforceable and
- cannot be replaced by organization policy.
-3. CPU, memory, PID, aggregate disk, and per-file size limits are enforceable.
-4. Read-only seed/script mounts have explicit guest targets and expose no broker
- state, credentials, sibling repository, or prior invocation.
-5. Timeout, OOM, PID, disk, file-size, malformed/oversized output, and
- interruption cleanup tests all pass.
-6. Unix and authenticated sbx ingress retain byte-identical protocol behavior.
-
-Passing a version check alone, or passing only the CLI help probe, is not enough
-to promote the backend.
-
-## Sensitivity categories
-
-Every repository carries a fixed sensitivity that sets an immutable maximum number of bits the broker may reveal about that repository across the entire AWF run. The budget is per-run only; the broker has no durable state across runs.
-
-| Sensitivity | Run budget | Notes |
-|---|---|---|
-| `public` | unmetered | Responses are never debited against a ledger, but are still schema- and operationally bounded |
-| `internal` | 64 bits/run | Default for legacy bare-string entries |
-| `confidential` | 8 bits/run | |
-| `sealed` | 0 bits/run | Can never fund even the cheapest query; seed is staged and validated but Python is never launched |
-
-The minimum charge for any single invocation is 4 bits (see [information charge](#information-charge)), so a `confidential` repository can fund at most two questions before its budget is exhausted, and a `sealed` repository can never be queried.
-
-Sensitivity is set in AWF configuration only. The generated skill advertises each configured repository's sensitivity and initial run budget so the agent can design an affordable schema. A request cannot supply or override sensitivity, and the broker never exposes the remaining ledger balance.
-
-## Information charge
-
-Every accepted invocation is charged from its repository's run budget **before** any seed is copied or Python is launched. The charge is never refunded regardless of outcome.
-
-```
-charge = 1 (ok/error distinction is itself observable)
- + ceil(log2(cardinality)) (the declared response schema)
- + 3 (six timing buckets; ceil(log2(6)) = 3)
-```
-
-**Cardinality** is the number of distinguishable values the schema admits: 1 for `const`, 2 for `boolean`, N for an N-member `enum`, `max - min + 1` for `integer`, the product of field cardinalities for `object`/`tuple`/`array`, the sum of variant cardinalities for `union`. Cardinality is computed with `BigInt` arithmetic so it cannot overflow.
-
-**Cost examples**
-
-| Schema | Cardinality | charge |
-|---|---|---|
-| `{"type":"const","value":42}` | 1 | 1 + 0 + 3 = **4 bits** |
-| `{"type":"boolean"}` | 2 | 1 + 1 + 3 = **5 bits** |
-| `{"type":"enum","values":["MIT","Apache-2.0","GPL-3.0","unknown"]}` | 4 | 1 + 2 + 3 = **6 bits** |
-| `{"type":"integer","minimum":0,"maximum":255}` | 256 | 1 + 8 + 3 = **12 bits** |
-
-An `internal` repository with a 64-bit budget can fund 12 consecutive boolean questions (60 bits), leaving 4 bits for one `const` question. If every query uses a `const` schema, it can fund 16 questions.
-
-`maxInvocations` is a separate, independent operational limit. It counts every response -- including those rejected by schema validation, budget exhaustion, or malformed requests -- and is unrelated to the bit ledger.
-
-## Timing buckets
-
-Query response latency is itself a side channel: a script that exits early on one code path and runs longer on another leaks information through wall-clock time. The broker makes every launched invocation's observable response time land on one of six fixed boundaries:
-
-| Bucket | Boundary |
-|---|---|
-| 1 | 10 ms |
-| 2 | 100 ms |
-| 3 | 1 s |
-| 4 | 10 s |
-| 5 | 60 s |
-| 6 | 600 s |
-
-The broker returns at the first bucket boundary at or after processing (execution + validation + container removal + workspace teardown) actually completes. Container and workspace cleanup are included in the measurement, so cleanup duration cannot be observed as a separate residual channel.
-
-**Scheduler tolerance.** A public 5 ms tolerance covers ordinary timer jitter. If a timer wakes more than 5 ms late or the selected boundary has already passed, the broker re-resolves to the next fixed boundary rather than responding at the late, continuously varying time.
-
-**Timing overflow.** If pathological infrastructure pushes total processing past the last bucket (600 s), the broker discards the result -- even a successful one -- and returns the canonical error. The 540-second timeout cap exists to preserve the final 60 seconds of the last bucket for cleanup.
-
-The three timing bits are charged as part of every accepted invocation's budget because latency alone is observable.
-
-## Agent interface
-
-When bounded queries are enabled a Compose agent receives:
-
-- A Unix socket directory (read-write) mounted at `$AWF_BOUNDED_QUERY_SOCKET`
-- A generated skill and `bounded-query` executable in one read-only directory
-- `AWF_BOUNDED_QUERY_REPOS` -- a comma-separated list of configured repo slugs
-
-An sbx primary agent is probed before staging to determine whether its
-filesystem passthrough supports connecting to a host Unix socket. When it does,
-the same Unix protocol is used. Otherwise the broker listens on a dedicated
-Docker `internal` network with one ephemeral port published only on the Docker
-host-gateway address; sbx reaches that service through `host.docker.internal`. The
-agent receives only the endpoint and a random per-run capability. The
-capability is not written to the generated skill, Compose/audit artifacts,
-query environments, or logs. A one-shot pre-agent probe proves the selected
-path is reachable; failure aborts before the primary agent starts.
-
-The sbx transport uses the same `/query` framing, body/header caps, canonical
-response bytes, serialized scheduler, timing buckets, and protected audit
-semantics as the Unix transport. It has no health or diagnostic route.
-Authentication failures return the same canonical error as every other
-failure. The broker remains absent from `awf-net` and `awf-ext`; its dedicated
-network is internal and has no outbound route.
-
-The generated skill lists each repository's configured sensitivity and initial run budget. It does not expose the broker's remaining ledger balance.
-
-GitHub tokens are removed from the agent environment whenever bounded queries are enabled, independently of the API and CLI proxies.
-
-The `bounded-query` command is installed on the agent's `PATH` and is the only supported way to invoke a query.
-
-### Invoking the `bounded-query` command
-
-```
-bounded-query --repo --schema '' < script.py
-```
-
-- `--repo` must appear exactly once. The value must be a valid `owner/repo` slug matching a configured repository.
-- `--schema` must appear exactly once. The value is a JSON document (at most 4096 bytes) conforming to the finite schema DSL.
-- The query script arrives on **stdin**. Interactive terminals are rejected.
-- Any other flag, the `--flag=value` form, and positional arguments are rejected without contacting the broker.
-
-The command always prints exactly one canonical JSON line to stdout, writes nothing to stderr, and exits with status 0 -- for both outcomes and for every failure, including transport failures.
-
-### Practical example
-
-Ask whether a repository contains a `SECURITY.md` at its root. Schema cardinality is 2, charge is 5 bits from the repository's run budget.
-
-```bash
-bounded-query \
- --repo my-org/private-service \
- --schema '{"type":"boolean"}' \
- <<'EOF'
-import json, os
-
-result = os.path.isfile('/query/repo/SECURITY.md')
-with open('/query/out', 'w') as f:
- json.dump(result, f)
-EOF
-```
-
-On success:
-
-```json
-{"status":"ok","result":true}
-```
-
-On any failure (invalid repo, exhausted budget, script crash, timeout, non-conformant output, etc.):
-
-```json
-{"status":"error"}
-```
-
-**Query environment.** The script runs as an unprivileged user (uid 65534) with no network and a read-only filesystem, except for `/query`. The repository tree is at `/query/repo/`. The script must write exactly one JSON value conforming to the declared schema to `/query/out`. Stdout and stderr are discarded and never reach the agent.
-
-## Finite response schema DSL
-
-The schema the agent declares is a closed algebra -- not general JSON Schema. Supported node types:
-
-| Type | Shape | Cardinality |
-|---|---|---|
-| `const` | `{"type":"const","value":}` | 1 |
-| `boolean` | `{"type":"boolean"}` | 2 |
-| `enum` | `{"type":"enum","values":[,...]}` | number of members |
-| `integer` | `{"type":"integer","minimum":N,"maximum":M}` | M - N + 1 |
-| `object` | `{"type":"object","fields":{"name":,...}}` | product of field cardinalities |
-| `tuple` | `{"type":"tuple","items":[,...]}` | product of item cardinalities |
-| `array` | `{"type":"array","items":,"length":N}` | item cardinality to the power N |
-| `union` | `{"type":"union","variants":{"tag":,...}}` | sum of variant cardinalities; value is `{"tag":"","value":<...>}` |
-
-A literal (used in `const` and `enum`) must be a string (at most 64 bytes UTF-8, no control characters), a safe integer, a boolean, or `null`. All `enum` values must share the same JSON type and must be unique.
-
-There is no way to express an unbounded string, a float, a regex, recursion, `$ref`, an optional field, `additionalProperties`, or an untagged/overlapping union. These are structurally impossible to write in the DSL, not merely rejected by a validator.
-
-### Schema size limits
-
-| Bound | Value |
-|---|---|
-| Max serialized schema size | 4096 bytes |
-| Max nesting depth | 6 |
-| Max total schema nodes | 64 |
-| Max `enum` values | 4096 |
-| Max `object` fields | 16 |
-| Max `tuple` items | 16 |
-| Max fixed `array` length | 64 |
-| Max `union` variants | 16 |
-| Max literal string length | 64 bytes |
-
-In practice the 4096-byte size limit is the binding constraint for wide `enum` or `object` schemas well before the count limits are reached.
-
-### Validation and canonicalization
-
-The schema is validated **before** the broker copies a seed or launches Python. If the schema is structurally invalid the request is rejected immediately (canonical error) without touching the repository.
-
-After the script exits, the result file is parsed with a non-backtracking hand-written parser that rejects malformed JSON, duplicate object keys, leading or trailing content, and invalid UTF-8. The parsed value is then validated against the exact declared schema. A value that passes is canonically re-serialized before being wrapped in the response envelope -- the exact byte layout written by the query (whitespace, key order) never reaches the agent.
-
-## Failure semantics
-
-All failure modes collapse to a single canonical response:
-
-```json
-{"status":"error"}
-```
-
-Failures that map to this response include: invalid request format, schema validation failure, repo not in `privateRepos`, exhausted bit budget, exhausted `maxInvocations`, query launch failure, timeout, script crash, non-conformant output, timing-bucket overflow, and internal broker errors.
-
-Failures are indistinguishable from each other by design: the agent cannot infer which failure mode occurred from the response alone.
-
-`maxInvocations` counts **every** response, including rejected requests. It is a separate operational limit unrelated to per-repository bit budgets. Once exhausted, all further requests return `{"status":"error"}` without consulting the bit ledger.
-
-Failure details (with protected labels such as `repo-not-allowed`, `bit-budget-exhausted`, `invalid-request`, `launch-failed`, `timing-bucket-overflow`, and `cleanup-failed`) are written only below the dedicated broker-private root (`/var/tmp/awf-bounded-query-private--/audit/`). The root is rejected before staging if realpath-aware preflight finds any overlap with a Compose, gVisor, or sbx agent mount. They are never returned to the agent.
-
-## Security limitations
-
-Bounded queries provide a **quantitative information bound**, not a semantic approval of disclosed content:
-
-- The bit budget limits how many bits of signal the broker may reveal, not whether any particular fact is sensitive.
-- Timing is included in the charge and bucketed, but six bucket outcomes are still observable (3 bits). Repeated queries can reveal additional bucket outcomes, and each accepted invocation pays that timing charge.
-- Agent-authored code is arbitrary Python within the sandbox. The sandbox enforces isolation, but a query can compute and express any value that fits the declared schema.
-- `public` repositories are unmetered. The schema and operational limits (`maxInvocations`, timeouts, sandboxing) still apply, but there is no bit ledger to exhaust.
-- Budgets reset each AWF run. The broker has no durable identity or storage across runs.
-- Classifying a repository's sensitivity level is an operator responsibility. Selecting a less restrictive category with a larger budget than warranted undermines the bound the feature provides.
-
-## See also
-
-- [Bounded agents](bounded-agents.md) - The agentic sibling: a configured native coding-agent engine in an enclave whose only reachable peer is the AWF API proxy. Prefer a bounded query whenever a deterministic script can answer the question, because a bounded agent necessarily discloses repository-derived content to the configured model provider.
-- [Security Architecture](/gh-aw-firewall/reference/security-architecture) - Firewall trust model and isolation layers
-- [AWF config spec section 14](https://github.com/github/gh-aw-firewall/blob/main/docs/awf-config-spec.md#14-bounded-queries) - Normative specification with full field constraints, protocol details, and staging implementation notes
diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md
index 0693afe78..27b7f0d8b 100644
--- a/docs/enclaves-architecture.md
+++ b/docs/enclaves-architecture.md
@@ -2,77 +2,21 @@
## Status
-Layer 4 of the staged migration connects the AWF-owned MCP server exclusively
-through `gh-aw-mcpg`. The primary agent receives no enclave socket, capability,
-direct URL, repository list, control root, ledger, or private state. Both legacy
-runtimes remain unchanged for the final cutover layer.
+Layer 5 removes the legacy bounded-query and bounded-agent surfaces. AWF now documents one `enclaves` subsystem, one AWF-owned MCP server, and mcpg-only access through the compiler handoff contract.
-## Decision
+## Architecture
-AWF will replace `boundedQueries` and `boundedAgents` with one `enclaves`
-subsystem. Trusted configuration declares a shared set of private repositories,
-their sensitivities, and two executor kinds:
+AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, and exposes enabled executors only through `gh-aw-mcpg`.
-- **script** runs a fixed interpreter in a no-network sandbox;
-- **agent** runs a fixed native agent on an API-proxy-only network.
+- **Script executor** — `enclave_run_script` runs a bounded Python script in a no-network, read-only, single-use sandbox.
+- **Agent executor** — `enclave_run_agent` runs the pinned Copilot engine in a bounded single-use enclave whose only network peer is the dedicated API proxy.
+- **Shared controls** — `enclaves.privateRepos` is the only trusted repository list; script and agent calls debit the same per-run repository ledger and share one admission lane.
-Runtime, image, model, network, timeout, resource, mount, credential, and tool
-settings are trusted AWF configuration. An enclave invocation may select only an
-allowed repository, a finite response schema, and executor-specific bounded
-input. It can never provide or override trusted controls.
+The primary agent never receives a broker socket, wrapper binary, direct MCP server URL, capability, repository seed, ledger state, or alternate transport.
-Every repository has **one information-budget ledger for the AWF run**. Script
-and agent invocations debit the same balance. Selecting a different executor
-does not create a second budget, and charges are never refunded after an
-invocation is admitted.
+## Tool contracts
-## Target trust boundaries
-
-1. **AWF host orchestration (trusted).** AWF validates configuration, proves
- runtime capabilities, stages immutable repository seeds, creates private
- state, launches the enclave MCP server, and owns cleanup. Staging credentials
- exist only here.
-2. **Enclave MCP server (trusted, AWF-owned).** AWF owns and launches the server.
- It loads trusted executor configuration and the single repository ledger,
- admits finite-schema requests, launches isolated executors, canonicalizes one
- finite result, and protects audit state. It is not a user-supplied MCP server.
-3. **`gh-aw-mcpg` (trusted policy gateway).** The primary agent can reach the
- enclave server only through `gh-aw-mcpg`. The gateway guards the tool surface
- and calls the AWF-owned server; it does not receive repository seeds,
- credentials, executor configuration, or ledger state.
-4. **Executor enclave (untrusted workload).** Each invocation receives only its
- selected immutable seed and bounded input. Script execution has no network.
- Agent execution can reach only its dedicated API proxy. Neither can reach the
- primary agent, MCP gateway, server control state, another executor, or host
- state.
-5. **Primary agent (untrusted caller).** It sees only MCP tool schemas and one
- canonical finite success/error response. It cannot access a broker socket,
- direct executor command, private seed, audit record, or remaining budget.
-
-Repository-derived content processed by an agent executor reaches the configured
-model provider through the API proxy. The information ledger bounds what the
-primary agent learns; it does not bound what the provider sees.
-
-## Startup and readiness
-
-The script service is an offline Compose service. AWF stages immutable seeds and
-creates a run-unique private root before Compose generation. Compose pre-pulls or
-builds the script image, then starts the MCP server only on the internal control
-network.
-The server owns the Docker socket, seed map, shared ledger, protected audit
-state and a run-scoped capability token. The server joins only the dedicated
-`internal` `awf-enclave-mcp-control` network under the stable
-`awf-enclave-mcp:8080` identity. There is no published host port. AWF attaches
-only the compiler-labelled external gateway to that network after verifying its
-run identity. Neither the capability nor any direct transport is mounted into or
-exported to the primary agent.
-
-When the agent executor is enabled, AWF additionally pre-pulls or builds the
-`enclave-agent` image, creates the dedicated `internal` `awf-enclave-agent`
-network (172.31.0.0/24), and starts a dedicated API proxy on that network plus a
-separate egress bridge. The MCP server itself never joins either network.
-
-The server exposes one static MCP tool per **enabled** executor:
+The AWF-owned MCP server publishes only the enabled enclave tools:
```text
enclave_run_script({
@@ -88,178 +32,74 @@ enclave_run_agent({
})
```
-Both tool schemas set `additionalProperties: false`. No image, runtime, engine,
-model, provider, profile, endpoint, mount, network, tool definition, system
-prompt, message list, credential, timeout, or resource setting is accepted in a
-tool call, and the alternate payload spelling (`task` for the agent tool,
-`prompt` for the script tool) is an explicitly forbidden control so a second
-payload can never be smuggled past the finite-disclosure charge. The agent
-executor runs a fixed, AWF-authored model loop inside the enclave — the caller
-supplies a prompt, never a system prompt, a message list, or a tool set.
-
-`tools/list` publishes exactly the enabled tools and does not reveal
-repositories, sensitivity, remaining budget, invocation counts, runtime, engine,
-profile, or model configuration. Admitted executions debit the *same* live
-per-repository ledger under executor kind `script` or `agent`; both executors
-also share one serialization lane, so at most one enclave holds private
-repository content at a time. The HTTP surface admits at most one tool call into
-that lane; a concurrent call receives the canonical error immediately rather
-than creating an unbounded queue of fixed timing buckets.
-
-### Agent executor isolation
-
-Every agent invocation gets a fresh, single-use, labelled enclave with:
-
-- the immutable repository seed bind-mounted read-only and a `--read-only` root;
-- bounded `tmpfs` for `/tmp` and the `/agent` work/result root;
-- a fixed non-root uid/gid, `--cap-drop ALL`, `no-new-privileges`, and the
- audited sandbox seccomp profile;
-- memory, CPU, PID, per-file size, and wall-clock timeout bounds;
-- `--network awf-enclave-agent` as its only network, whose only other member is
- the dedicated API proxy — no primary agent, Squid, general API proxy, MCP
- server, safe-outputs collector, MCP gateway, or CLI proxy is on it.
-
-Containers carry the `awf.enclave.run` and `awf.enclave.invocation` labels, so
-one AWF-side reconciliation pass deterministically removes orphans from both
-executors. `runtime: "sbx"` is schema-accepted but fails closed before staging;
-`gvisor` requires an exactly registered `runsc` and never downgrades.
-
-### Credential and provider disclosure
-
-The dedicated API proxy is the only component that holds a real provider
-credential. The MCP server, the enclave, and the primary agent never do. That
-proxy's environment is minimized to the single provider route the configured
-engine/profile uses, and external telemetry export (OTLP endpoints/headers,
-trace propagation) plus Actions OIDC token-exchange state are removed from it,
-exactly as for legacy bounded agents. Its telemetry is written only to the
-enclave-private log root.
-
-Executor outcomes return successful JSON-RPC tool results whose
-`structuredContent` is exactly canonical `{"status":"ok","result":...}` or
-`{"status":"error"}`. Secret-dependent failures never use JSON-RPC errors or
-`isError`. Cleanup remains inside the fixed timing bucket.
-
-`gh-aw-mcpg` startup may precede AWF's enclave server startup. Each upstream
-attempt is bounded by the server's `connectTimeout`; `gateway.startupTimeout`
-applies only to stdio process startup and does not extend HTTP attempts. While
-the HTTP upstream is unavailable, mcpg returns retryable HTTP 503
-`backend_unavailable`. AWF retries the complete `initialize` handshake with a
-bounded 500 ms backoff until `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` expires.
-Each request is capped by the remaining readiness budget. All other HTTP,
-authentication, protocol, and tool-contract failures are terminal. Neither
-component may silently downgrade or bypass the gateway while waiting, and
-readiness errors never log response bodies, headers, or capabilities.
-
-The primary agent must not start until AWF has proved readiness end to end:
-
-1. the AWF-owned enclave MCP server is healthy on its private control network;
-2. the running gateway name and `com.github.gh-aw.mcpg.run` label match the
- compiler handoff;
-3. AWF attaches that gateway to the control network and proves it is the only
- member besides the server;
-4. `initialize`, `notifications/initialized`, and `tools/list` traverse the
- gateway's published route;
-5. the returned server identity and complete tool contracts exactly match the
- enabled executor set.
-
-A timeout, identity mismatch, failed proof, or unavailable executor capability
-fails the run before repository staging is exposed or the primary agent starts.
-
-### Compiler-generated mcpg handoff
-
-The compiler must generate this upstream entry before starting `awmg-mcpg`:
-
-```json
-{
- "awf-enclave": {
- "type": "http",
- "url": "http://awf-enclave-mcp:8080/mcp",
- "headers": {
- "Authorization": "Bearer ${AWF_ENCLAVE_MCP_CAPABILITY}"
- },
- "tools": ["enclave_run_script", "enclave_run_agent"],
- "connectTimeout": 120,
- "toolTimeout": 630
- }
-}
-```
-
-The `tools` array must contain only enabled executor tools. `toolTimeout` is 630
-seconds: the maximum 600-second fixed disclosure bucket plus a bounded 30-second
-gateway transport allowance. The compiler must
-generate a fresh 64-character lowercase hexadecimal
-`AWF_ENCLAVE_MCP_CAPABILITY`, pass it to mcpg for header substitution and to the
-AWF host process, and never pass it to the primary agent. It must also:
-
-- launch the externally owned gateway as `awmg-mcpg`;
-- label it `com.github.gh-aw.mcpg.run=`;
-- set `AWF_ENCLAVE_MCP_GATEWAY_IDENTITY` to that exact identity for AWF;
-- set `AWF_ENCLAVE_MCP_GATEWAY_CONTAINER=awmg-mcpg`;
-- set `AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT` to the host-reachable gateway route
- ending in `/mcp/awf-enclave`;
-- optionally set `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` to a bounded
- 1000-600000 ms value (default 120000);
-- configure the upstream `connectTimeout` to 120 seconds; do not rely on the
- stdio-only `gateway.startupTimeout` for HTTP recovery.
-- enable AWF network isolation and include `awmg-mcpg` in `topologyAttach`, so
- Compose agents reach only the gateway on `awf-net` while AWF separately
- attaches the same verified container to the enclave control network.
-
-`buildEnclaveMcpgUpstreamContract()` is the machine-readable AWF source of truth
-for the static entry and handoff names. AWF excludes all handoff variables from
-agent environment passthrough, including `--env-all`.
-
-This contract requires MCP Gateway specification 1.15.0 and the first mcpg
-release after v0.4.8 containing github/gh-aw-mcpg#10784. The current gh-aw
-compiler does not yet emit this enclave upstream, capability,
-identity label, or readiness endpoint. It requires a companion change in
-`pkg/workflow/mcp_setup_gateway.go`, `mcp_gateway_config.go`,
-`mcp_renderer.go`, and `awf_config.go`. Current mcpg releases must also support
-retrying an initially unavailable HTTP upstream without permanently omitting its
-tools; otherwise the compiler must delay/restart mcpg after AWF infrastructure
-readiness. AWF does not restart or take ownership of mcpg.
-
-The enclave server alias never enters the primary agent's `NO_PROXY`, Squid ACL,
-static hosts, mounts, or environment. Only `awmg-mcpg` remains an agent-visible
-topology peer. Thus proxy-aware and proxy-ignoring clients cannot use Squid as an
-alternate path to the enclave server.
-
-### Shutdown
-
-After primary-agent work stops, AWF sends the enclave server `SIGTERM` with a
-630-second bounded stop grace: the maximum 600-second fixed disclosure bucket
-plus a 30-second stop allowance. The server closes admissions, drains the single execution lane,
-reconciles labelled enclaves, and exits. AWF then disconnects
-the external gateway from `awf-enclave-mcp-control`; Compose removes the
-AWF-owned server and network, and host cleanup removes the private roots. AWF
-never stops or removes `awmg-mcpg`.
-
-## Migration sequence
-
-1. **Foundation.** Add strict `enclaves` config, neutral finite
- disclosure/staging/budget contracts, shared-ledger semantics, and compatibility
- exports. Keep both legacy systems fully functional and reject simultaneous
- enablement of a unified and legacy surface.
-2. **AWF-owned script MCP server.** Implement the authenticated, offline local
- server and hardened script executor over the shared contracts; do not expose
- its private transport to the primary agent.
-3. **Agent executor.** Add the fixed model loop, the dedicated
- API-proxy-only enclave network, and the `enclave_run_agent` tool behind the
- same MCP server, authenticated private transport, and shared ledger. The
- private transport is not exposed to the primary agent.
-4. **`gh-aw-mcpg` integration (this layer).** Register and guard the AWF-owned server, wire
- startup retry/timeouts, require end-to-end readiness before primary-agent
- startup, and route both executor tools exclusively through the gateway.
-5. **Runtime cutover and legacy removal.** Move all callers to the unified MCP
- surface, prove canonical-response and isolation parity, then remove
- `boundedQueries`, `boundedAgents`, their direct agent surfaces, brokers,
- compatibility exports, images, docs, and tests. The unified mcpg path becomes
- the sole supported runtime.
-
-## Compatibility
-
-This layer adds no primary-agent enclave mount, environment variable, wrapper,
-skill, direct URL, or fallback and does not alter legacy protocol bytes.
-Existing `boundedQueries` and `boundedAgents` configurations continue to run as
-before. Unified and legacy configurations remain mutually exclusive and fail
-closed before staging.
+Both tool schemas are closed (`additionalProperties: false`). Callers cannot provide images, runtimes, models, profiles, prompts beyond the bounded payload field, repository catalogs, credentials, timeout overrides, or any other trusted control.
+
+`tools/list` publishes exactly the enabled tools without revealing repositories,
+sensitivity, remaining budget, invocation counts, runtime, engine, profile, or
+model configuration. Both executors debit the same live per-repository ledger
+and share one serialization lane. A concurrent tool call receives the canonical
+error immediately instead of entering an unbounded fixed-timing queue.
+
+## Topology and readiness
+
+- `enclave-mcp-server` joins only the private `awf-enclave-mcp-control` network.
+- The compiler launches `gh-aw-mcpg`, labels it for the run, and gives AWF the gateway identity plus the private `/mcp/awf-enclave` endpoint.
+- The server is reachable **only** through that gateway. AWF never publishes the server on a host port and never hands the primary agent a direct route.
+- When the agent executor is enabled, each invocation joins only the private `awf-enclave-agent` network; its only peer is the dedicated enclave API proxy.
+
+Rollout depends on both sides of the gateway contract:
+
+1. **Compiler handoff contract** — `github/gh-aw#50920` must emit the enclave upstream, capability, identity label, endpoint, and timeout handoff.
+2. **Late backend rediscovery** — `github/gh-aw-mcpg#10784` must preserve an initially unavailable HTTP backend and rediscover it later.
+3. **Gateway/runtime requirement** — this requires MCP Gateway spec **1.15.0** and the **first mcpg release after v0.4.8 containing it**.
+
+The compiler-generated upstream uses `connectTimeout: 120` and
+`toolTimeout: 630`, covering the maximum 600-second disclosure bucket plus a
+bounded transport allowance. Its tool allowlist contains only the enabled
+executor tools. The compiler generates a fresh 64-character lowercase
+hexadecimal capability, substitutes it into the mcpg authorization header, and
+passes it to AWF without exposing it to the primary agent.
+
+`gh-aw-mcpg` may start before the enclave server. While the backend is
+unavailable, mcpg returns retryable HTTP `503 backend_unavailable`; AWF retries
+the complete `initialize` handshake with bounded 500 ms backoff until
+`AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` expires. Each request is capped by the
+remaining readiness budget. Other HTTP, authentication, protocol, and tool
+contract failures are terminal. Neither component may downgrade or bypass the
+gateway, and readiness errors never log response bodies, headers, or
+capabilities.
+
+After primary-agent work stops, AWF gives the enclave server a bounded
+630-second stop grace. The server closes admissions, drains its single execution
+lane, reconciles labelled enclaves, and exits before AWF preserves audit
+artifacts and disconnects mcpg from the private control network. AWF never stops
+or removes the externally owned mcpg container.
+
+## Migration and removals
+
+The following legacy surfaces are **removed, not deprecated**:
+
+| Removed surface | Replacement |
+| --- | --- |
+| `boundedQueries` config | `enclaves.privateRepos` + `enclaves.executors.script` |
+| `boundedAgents` config | `enclaves.privateRepos` + `enclaves.executors.agent` |
+| `bounded-query` wrapper / generated skill | `enclave_run_script` MCP tool |
+| `bounded-agent` wrapper / generated skill | `enclave_run_agent` MCP tool |
+| Separate per-subsystem ledgers | One shared per-repository ledger inside `enclave-mcp-server` |
+| Direct legacy runtime surfaces | Compiler-launched `gh-aw-mcpg` handoff only |
+
+Mixed legacy + unified configuration is no longer a compatibility mode. Tooling should remove the old keys rather than carrying both.
+
+## Coverage after legacy smoke removal
+
+No unified gh-aw enclave smoke workflow exists yet, so AWF keeps coverage local and unit-focused instead of inventing unsupported workflow syntax. Current owned-scope guidance points to:
+
+- `src/services/enclave-mcp-service.test.ts`
+- `src/services/enclave-agent-service.test.ts`
+- `src/enclave/script-runner-spec.test.ts`
+- `src/enclave/agent-runner-spec.test.ts`
+- `src/enclave/manager.test.ts`
+- `src/enclave/mcp-server.test.ts`
+- `src/enclave/agent-mcp-server.test.ts`
+
+These tests cover the shared MCP server contract, executor selection, gVisor wiring, fail-closed `sbx` handling, and the private-network topology assumptions that replaced the legacy smoke and runtime-matrix assets.
diff --git a/docs/github_actions.md b/docs/github_actions.md
index 60f00388d..d9106b323 100644
--- a/docs/github_actions.md
+++ b/docs/github_actions.md
@@ -36,7 +36,7 @@ The action:
| Output | Description |
|--------|-------------|
| `version` | The version that was installed (e.g., `v0.7.0`) |
-| `image-tag` | Image tag metadata for runtime containers. Format: `0.7.0` or `0.7.0,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,agent-act=sha256:...,cli-proxy=sha256:...`. Supported digest keys currently include `squid`, `agent`, `api-proxy`, `agent-act`, and `cli-proxy`; additional keys may appear in future releases. |
+| `image-tag` | Image tag metadata for runtime containers. Format: `0.7.0` or `0.7.0,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,agent-act=sha256:...,cli-proxy=sha256:...,enclave-script=sha256:...,enclave-agent=sha256:...,enclave-mcp-server=sha256:...`. Supported digest keys currently include `squid`, `agent`, `api-proxy`, `agent-act`, `cli-proxy`, `enclave-script`, `enclave-agent`, and `enclave-mcp-server`. |
#### Pinning Docker Image Versions
diff --git a/docs/releasing.md b/docs/releasing.md
index b7f36bf68..e88980b8d 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -44,6 +44,7 @@ Once the workflow completes:
- Linux arm64 binary (`awf-linux-arm64`)
- NPM tarball (`awf.tgz`)
- Checksums file (`checksums.txt`)
+ - Container digest manifest (`containers.txt`)
- JSON Schema files (`awf-config.schema.json`, `audit.schema.json`, `token-usage.schema.json`)
- Installation instructions with GHCR image references
3. Go to **Packages** page (in repository)
@@ -53,6 +54,9 @@ Once the workflow completes:
- `api-proxy:` and `api-proxy:latest`
- `cli-proxy:` and `cli-proxy:latest`
- `agent-act:` and `agent-act:latest` (GitHub Actions parity image)
+ - `enclave-script:` and `enclave-script:latest`
+ - `enclave-agent:` and `enclave-agent:latest`
+ - `enclave-mcp-server:` and `enclave-mcp-server:latest`
## Release Artifacts
@@ -63,6 +67,7 @@ Each release includes:
- `awf-linux-arm64` - Linux arm64 standalone executable
- `awf.tgz` - NPM package tarball (alternative installation method)
- `checksums.txt` - SHA256 checksums for all files
+- `containers.txt` - Digest-pinned container manifest for published runtime images
- `awf-config.schema.json` - AWF config JSON Schema
- `awf-config.v1.schema.json` - **Deprecated alias** of `awf-config.schema.json` (kept for backward compatibility)
- `audit.schema.json` - AWF audit JSONL record JSON Schema
@@ -99,6 +104,9 @@ Docker images are published to `ghcr.io/github/gh-aw-firewall`:
- `api-proxy:` and `api-proxy:latest` - API proxy sidecar for credential isolation
- `cli-proxy:` and `cli-proxy:latest` - CLI proxy sidecar for gh CLI access via mcpg DIFC proxy
- `agent-act:` and `agent-act:latest` - Agent with GitHub Actions parity (~2GB)
+- `enclave-script:` and `enclave-script:latest` - No-network script executor, built from `containers/enclave/Dockerfile` target `enclave-script`
+- `enclave-agent:` and `enclave-agent:latest` - Single-use agent executor, built from `containers/enclave/Dockerfile` target `enclave-agent`
+- `enclave-mcp-server:` and `enclave-mcp-server:latest` - Shared enclave MCP server, built from `containers/enclave/Dockerfile` target `enclave-mcp-server`
These images are automatically pulled by the CLI when running commands.
diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md
index abe13e125..e1d677067 100644
--- a/docs/sbx-integration.md
+++ b/docs/sbx-integration.md
@@ -80,33 +80,27 @@ it lets AWF interpose its own Squid proxy *underneath* Docker's sandbox proxy.
VMs persist until explicitly removed; stopping an agent does not delete the VM.
-### Bounded-query runtime is independent
+### Enclave runtimes are independent
`container.containerRuntime: "sbx"` selects the primary agent's execution
-model. `boundedQueries.runtime: "sbx"` is a separate backend behind the trusted
-broker's `QueryRunner` boundary and must never reuse the primary agent VM,
-agent-ingress capability, or agent credentials.
-
-The bounded-query sbx backend is currently a fail-closed preview. Docker
-Sandboxes `v0.37.1` has CPU/memory limits and read-only same-path mounts, but
-does not expose enforceable per-VM network-none, PID, disk, per-file size, or
-guest mount-target controls. Local/kit network denies can also be replaced by
-organization governance. AWF's executable capability probe therefore blocks
-this query backend before staging or Compose assembly; no sbx daemon access is
-passed to the broker and there is no Docker/gVisor fallback. See
-[Bounded Queries](bounded-queries.md#sbx-query-runtime-status).
-
-The full 3×3 primary/query matrix is documented in
-[Bounded Queries](bounded-queries.md#primary-agent-and-query-runtime-matrix).
-All sbx-query cells are intentionally blocked; Docker and gVisor query
-backends may run under an sbx primary agent only after its independent broker
-ingress probe passes. Every query gets a new sandbox and no backend falls back.
-
-Promotion is gated on a digest-pinned Python-only template and real-VM proof of
+model. `enclaves.executors.script.runtime: "sbx"` and
+`enclaves.executors.agent.runtime: "sbx"` are separate enclave backends
+behind the AWF-owned MCP server and must never reuse the primary agent VM,
+agent-ingress capability, gateway capability, or agent credentials.
+
+Both enclave sbx backends are currently fail-closed previews. Docker Sandboxes
+`v0.37.1` has CPU/memory limits and read-only same-path mounts, but it still
+does not prove the full network, PID, disk, per-file size, guest mount-target,
+and lifecycle controls AWF requires for unified enclaves. AWF therefore blocks
+both enclave sbx backends before staging or Compose assembly, with no
+Docker/gVisor fallback.
+
+Promotion is gated on a digest-pinned template plus real-VM proof of
network/lateral denial, PID/memory/CPU/disk/file-size enforcement, explicit
guest mount targets, credential and cross-invocation isolation, canonical
-failure bytes, timing buckets, and interruption cleanup. Docker Sandboxes
-`v0.37.1` cannot satisfy those controls.
+failure bytes, timing buckets, and interruption cleanup. See
+[AWF configuration spec §14](awf-config-spec.md#14-unified-enclaves) and
+[Unified Enclave Architecture and Migration](enclaves-architecture.md).
## Part 2 — How AWF uses `sbx`
@@ -278,9 +272,9 @@ and, when true, substitutes two functions into the shared workflow runner:
2. Builds the agent environment (`buildAgentEnvironment`) using microVM-specific
network targets (see below), merging credential env
(`buildAgentCredentialEnv`) when the api-proxy is enabled.
- 3. When bounded queries are enabled, resolves the trusted broker ingress,
- mounts only its skill/wrapper directory (plus the socket directory when
- Unix passthrough was proven), and probes reachability before agent startup.
+ 3. When enclaves are enabled, waits for mcpg to prove the enclave MCP backend
+ is registered and reachable before primary-agent startup. No enclave
+ endpoint, capability, repository list, or private state enters the VM.
4. Calls `createSandbox({ workspaceDir, squidIp: SQUID_IP, extraMounts })`.
5. When the api-proxy is enabled, runs `assertSbxApiProxyReflect`: creates a
private `HOSTALIASES` resolver file mapping `api-proxy` to a loopback HTTP
@@ -315,14 +309,12 @@ reachable** — the VM is on its own network. AWF compensates with two indirecti
`host.docker.internal`, which resolves to the docker0 bridge from inside the
VM. `COPILOT_*` / proxy env vars are pointed there instead of at
`172.30.0.30`.
-- **The bounded-query broker** uses a mounted Unix socket when an executable
- disposable-sandbox probe proves sbx passthrough supports host sockets.
- Otherwise it uses an authenticated HTTP endpoint on an ephemeral
- host-gateway-only port that `host.docker.internal` can reach from inside the
- VM. The broker is attached only to a dedicated Docker
- `internal` network, not `awf-net` or `awf-ext`, so this ingress does not add
- broker egress. The actual primary sandbox must pass a one-shot endpoint probe
- before its agent command starts.
+- **The enclave control plane** is not mounted into the sbx primary sandbox.
+ When unified enclaves are enabled, the primary agent reaches them only
+ through the externally launched `gh-aw-mcpg` gateway after AWF proves the
+ run-labelled handoff and end-to-end readiness. The AWF-owned
+ `enclave-mcp-server` stays on its own private control network, outside
+ `awf-net` and `awf-ext`.
The net effect: agent tools that respect `HTTP_PROXY`/`HTTPS_PROXY` route through
AWF's Squid domain ACL; credentials are injected by AWF's api-proxy. Tools that
diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs
index 5cc6072f4..2a8c79e2c 100644
--- a/scripts/build-bundle.mjs
+++ b/scripts/build-bundle.mjs
@@ -31,12 +31,6 @@ try {
process.exit(1);
}
-const boundedQueryWrapperPath = join(projectRoot, 'containers', 'agent', 'bounded-query-wrapper.sh');
-const boundedQueryWrapperContent = readFileSync(boundedQueryWrapperPath, 'utf-8');
-
-const boundedAgentWrapperPath = join(projectRoot, 'containers', 'agent', 'bounded-agent-wrapper.sh');
-const boundedAgentWrapperContent = readFileSync(boundedAgentWrapperPath, 'utf-8');
-
// Ensure output directory exists
mkdirSync(join(projectRoot, 'release'), { recursive: true });
@@ -53,8 +47,6 @@ await build({
// can produce a duplicate shebang that breaks `node` execution.
define: {
__AWF_SECCOMP_PROFILE__: JSON.stringify(seccompContent),
- __AWF_BOUNDED_QUERY_WRAPPER__: JSON.stringify(boundedQueryWrapperContent),
- __AWF_BOUNDED_AGENT_WRAPPER__: JSON.stringify(boundedAgentWrapperContent),
},
// Mark native/optional deps as external if needed
// (none expected — all deps are pure JS)
diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts
index b04e382b8..f0d70f5fa 100644
--- a/scripts/ci/postprocess-smoke-workflows.ts
+++ b/scripts/ci/postprocess-smoke-workflows.ts
@@ -24,16 +24,9 @@ const codexWorkflowPaths = [
path.join(repoRoot, '.github/workflows/secret-digger-codex.lock.yml'),
];
-// Release-mode workflows that intentionally test PUBLISHED awf binaries and
-// PRE-BUILT GHCR container images (pinned to a concrete release) instead of the
-// repo's own source. These must NOT be post-processed: the local-build install
-// and --skip-pull -> --build-local rewrites would replace the released bundle
-// with a source build, which is incompatible (e.g. the standalone awf bundle
-// rejects --build-local: "requires a full repository checkout").
-//
-const releaseModeLockFiles = new Set([
- 'smoke-bounded-queries.lock.yml',
-]);
+// Release-mode workflows that intentionally test published binaries can be
+// excluded here if we add any in the future.
+const releaseModeLockFiles = new Set();
// Auto-discover all lock files so new workflows are automatically included.
// This avoids the recurring bug where adding a new workflow .md file and
diff --git a/scripts/ci/probe-bounded-agent-primary-sbx.js b/scripts/ci/probe-bounded-agent-primary-sbx.js
deleted file mode 100755
index f49095b1c..000000000
--- a/scripts/ci/probe-bounded-agent-primary-sbx.js
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-/**
- * Executable primary-sbx ingress proof used by runtime-matrix reporting.
- *
- * This intentionally proves only the Unix-socket passthrough path. The
- * authenticated HTTP path requires a live run-specific broker and is proven by
- * main-action before the primary agent starts; a standalone report cannot
- * safely synthesize that capability.
- */
-async function main() {
- const { probeSbxUnixSocketMount } = require('../../dist/sbx-manager.js');
- if (!(await probeSbxUnixSocketMount())) {
- process.stderr.write('BLOCKED: primary sbx Unix-socket ingress was not proven\n');
- process.exitCode = 1;
- return;
- }
- process.stdout.write('SUPPORTED: primary sbx Unix-socket ingress proven\n');
-}
-
-main().catch(() => {
- process.stderr.write('BLOCKED: primary sbx ingress capability probe failed\n');
- process.exitCode = 1;
-});
diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.js b/scripts/ci/report-bounded-agent-runtime-matrix.js
deleted file mode 100644
index eafa78263..000000000
--- a/scripts/ci/report-bounded-agent-runtime-matrix.js
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-const fs = require('fs');
-const { spawnSync } = require('child_process');
-
-const BACKENDS = ['docker', 'gvisor', 'sbx'];
-
-function run(command, args) {
- const result = spawnSync(command, args, {
- encoding: 'utf8',
- timeout: 30_000,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- return {
- ok: !result.error && result.status === 0,
- stdout: result.stdout || '',
- };
-}
-
-function collectCapabilities(commandRunner = run) {
- const docker = commandRunner('docker', ['info', '--format', '{{json .Runtimes}}']);
- let runtimes = {};
- if (docker.ok) {
- try {
- runtimes = JSON.parse(docker.stdout);
- } catch {
- runtimes = {};
- }
- }
- const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc');
- // Primary sbx is supported only after a disposable sandbox proves the actual
- // bounded-agent Unix-socket ingress path. CLI/daemon availability alone is
- // not an ingress proof and must never produce a SUPPORTED matrix cell.
- const sbxPrimary = commandRunner(
- process.execPath,
- ['scripts/ci/probe-bounded-agent-primary-sbx.js'],
- ).ok;
- const sbxBoundedAgent = commandRunner(
- process.execPath,
- ['containers/bounded-agent/broker/sbx-capability-probe.js'],
- );
- let sbxBoundedAgentSupported = false;
- if (sbxBoundedAgent.stdout) {
- try {
- sbxBoundedAgentSupported = JSON.parse(sbxBoundedAgent.stdout).supported === true;
- } catch {
- sbxBoundedAgentSupported = false;
- }
- }
- return {
- primary: {
- docker: docker.ok ? 'supported' : 'unavailable',
- gvisor: gvisor ? 'supported' : 'unavailable',
- sbx: sbxPrimary ? 'supported' : 'unavailable',
- },
- boundedAgent: {
- docker: docker.ok ? 'supported' : 'unavailable',
- gvisor: gvisor ? 'supported' : 'unavailable',
- sbx: sbxBoundedAgentSupported ? 'supported' : 'blocked',
- },
- };
-}
-
-/**
- * Evaluates one primary/bounded-agent combination. Primary sbx reaches
- * `supported` only when the collector's disposable sandbox has completed the
- * real Unix-socket broker-ingress exchange; CLI/daemon availability alone
- * remains `unavailable`.
- */
-function evaluate(primary, boundedAgent, capabilities) {
- const primaryState = capabilities.primary[primary];
- if (primaryState === 'available') {
- return {
- status: 'BLOCKED',
- capability: primaryState,
- phase: 'primary-sbx-ingress-unproven',
- };
- }
- if (primaryState !== 'supported') {
- return {
- status: 'BLOCKED',
- capability: primaryState,
- phase: 'primary-preflight',
- };
- }
- if (capabilities.boundedAgent[boundedAgent] !== 'supported') {
- return {
- status: 'BLOCKED',
- capability: capabilities.boundedAgent[boundedAgent],
- phase: 'bounded-agent-preflight',
- };
- }
- return { status: 'SUPPORTED', capability: 'supported', phase: 'ready' };
-}
-
-function renderMatrix(capabilities) {
- const lines = [
- '## Bounded-agent runtime capability matrix',
- '',
- '| Primary agent | Bounded-agent enclave | Result | Primary capability | ' +
- 'Bounded-agent capability | Gate |',
- '|---|---|---|---|---|---|',
- ];
- for (const primary of BACKENDS) {
- for (const boundedAgent of BACKENDS) {
- const result = evaluate(primary, boundedAgent, capabilities);
- lines.push(
- `| ${primary} | ${boundedAgent} | ${result.status} | ${capabilities.primary[primary]} | ` +
- `${capabilities.boundedAgent[boundedAgent]} | ${result.phase} |`,
- );
- }
- }
- lines.push(
- '',
- '> BLOCKED is an expected fail-closed security result, not runtime success. No fallback is attempted.',
- '> The bounded-agent sbx enclave is BLOCKED unconditionally today: the audited sbx CLI cannot yet ' +
- 'prove the mandatory API-proxy-only network, RO-targeted-mount, pids/disk/fsize, or lifecycle ' +
- 'isolation primitives this enclave requires.',
- '> Primary sbx is SUPPORTED here only after a disposable sandbox proves the Unix-socket broker ' +
- 'ingress path. Authenticated HTTP fallback is proven only by `assertSbxBoundedAgentIngress` ' +
- 'during an actual run.',
- );
- return `${lines.join('\n')}\n`;
-}
-
-function main() {
- const capabilities = collectCapabilities();
- const report = renderMatrix(capabilities);
- process.stdout.write(report);
- if (process.env.GITHUB_STEP_SUMMARY) {
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
- }
-
- const requiredIndex = process.argv.indexOf('--require');
- if (requiredIndex !== -1) {
- const requirement = process.argv[requiredIndex + 1] || '';
- const [primary, boundedAgent] = requirement.split('/');
- if (!BACKENDS.includes(primary) || !BACKENDS.includes(boundedAgent)) {
- throw new Error(`Invalid --require combination: ${requirement}`);
- }
- const result = evaluate(primary, boundedAgent, capabilities);
- if (result.status !== 'SUPPORTED') {
- throw new Error(`Required runtime combination ${requirement} is ${result.status} at ${result.phase}`);
- }
- }
-}
-
-if (require.main === module) {
- try {
- main();
- } catch (error) {
- process.stderr.write(`${error.message}\n`);
- process.exitCode = 1;
- }
-}
-
-module.exports = { collectCapabilities, evaluate, renderMatrix };
diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts
deleted file mode 100644
index 0f971592f..000000000
--- a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import * as path from 'path';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const { collectCapabilities, evaluate, renderMatrix } = require(
- path.join(__dirname, 'report-bounded-agent-runtime-matrix.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-describe('bounded-agent runtime capability report', () => {
- it('reports all nine combinations and preserves the sbx bounded-agent security block', () => {
- const capabilities = collectCapabilities((command: string, args: string[]) => {
- if (command === 'docker') {
- return { ok: true, stdout: '{"runc":{},"runsc":{}}' };
- }
- if (args.includes('scripts/ci/probe-bounded-agent-primary-sbx.js')) {
- return { ok: true, stdout: 'Docker Sandboxes v0.37.1' };
- }
- if (args.includes('sbx-capability-probe.js')) {
- return { ok: false, stdout: '{"supported":false}' };
- }
- return { ok: false, stdout: '' };
- });
- const report = renderMatrix(capabilities);
- const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line));
- expect(rows).toHaveLength(9);
- expect(report).toContain(
- '| sbx | sbx | BLOCKED | supported | blocked | bounded-agent-preflight |',
- );
- expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success');
- expect(report).toContain('bounded-agent sbx enclave is BLOCKED unconditionally today');
- });
-
- it('never promotes an unavailable primary or bounded-agent runtime through fallback', () => {
- const capabilities = {
- primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' },
- boundedAgent: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' },
- };
- expect(evaluate('gvisor', 'docker', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'unavailable',
- phase: 'primary-preflight',
- });
- expect(evaluate('docker', 'gvisor', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'unavailable',
- phase: 'bounded-agent-preflight',
- });
- expect(evaluate('docker', 'sbx', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'blocked',
- phase: 'bounded-agent-preflight',
- });
- });
-
- it('supports primary sbx paired with docker/gvisor bounded-agent enclaves once primary sbx is proven', () => {
- // `supported` means the collector's executable ingress probe completed.
- const capabilities = {
- primary: { docker: 'supported', gvisor: 'supported', sbx: 'supported' },
- boundedAgent: { docker: 'supported', gvisor: 'supported', sbx: 'blocked' },
- };
- expect(evaluate('sbx', 'docker', capabilities).status).toBe('SUPPORTED');
- expect(evaluate('sbx', 'gvisor', capabilities).status).toBe('SUPPORTED');
- expect(evaluate('sbx', 'sbx', capabilities).status).toBe('BLOCKED');
- });
-
- it('does not promote primary sbx when only its CLI and daemon are available', () => {
- const capabilities = collectCapabilities((command: string, args: string[]) => {
- if (command === 'docker') return { ok: true, stdout: '{"runc":{}}' };
- if (command === 'sbx' && args[0] === 'ls') return { ok: true, stdout: '[]' };
- return { ok: false, stdout: '' };
- });
- expect(capabilities.primary.sbx).toBe('unavailable');
- expect(evaluate('sbx', 'docker', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'unavailable',
- phase: 'primary-preflight',
- });
- });
-
- it('emits an explicit capability-blocked report (not a false pass) when no real sbx binary is present', () => {
- const capabilities = collectCapabilities((command: string) => {
- if (command === 'docker') {
- return { ok: true, stdout: '{"runc":{}}' };
- }
- // Simulate the local/CI environment used in this task: no `sbx` binary
- // installed at all, and no bounded-agent broker probe reachable.
- return { ok: false, stdout: '' };
- });
- expect(capabilities.primary.sbx).toBe('unavailable');
- expect(capabilities.boundedAgent.sbx).toBe('blocked');
- expect(() => {
- const report = renderMatrix(capabilities);
- const requirement = evaluate('sbx', 'sbx', capabilities);
- if (requirement.status !== 'SUPPORTED') {
- throw new Error(
- `Required runtime combination sbx/sbx is ${requirement.status} at ${requirement.phase}`,
- );
- }
- return report;
- }).toThrow(/sbx\/sbx is BLOCKED at primary-preflight/);
- });
-});
diff --git a/scripts/ci/report-bounded-query-runtime-matrix.js b/scripts/ci/report-bounded-query-runtime-matrix.js
deleted file mode 100644
index 0a58976af..000000000
--- a/scripts/ci/report-bounded-query-runtime-matrix.js
+++ /dev/null
@@ -1,133 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-const fs = require('fs');
-const { spawnSync } = require('child_process');
-
-const BACKENDS = ['docker', 'gvisor', 'sbx'];
-
-function run(command, args) {
- const result = spawnSync(command, args, {
- encoding: 'utf8',
- timeout: 30_000,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- return {
- ok: !result.error && result.status === 0,
- stdout: result.stdout || '',
- };
-}
-
-function collectCapabilities(commandRunner = run) {
- const docker = commandRunner('docker', ['info', '--format', '{{json .Runtimes}}']);
- let runtimes = {};
- if (docker.ok) {
- try {
- runtimes = JSON.parse(docker.stdout);
- } catch {
- runtimes = {};
- }
- }
- const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc');
- // `sbx version` only proves that the binary exists. Listing is authenticated
- // and non-mutating, so it also proves daemon and credential availability.
- const sbxPrimary = commandRunner('sbx', ['ls']).ok;
- const sbxQuery = commandRunner(
- process.execPath,
- ['containers/bounded-query/broker/sbx-capability-probe.js'],
- );
- let sbxQuerySupported = false;
- if (sbxQuery.stdout) {
- try {
- sbxQuerySupported = JSON.parse(sbxQuery.stdout).supported === true;
- } catch {
- sbxQuerySupported = false;
- }
- }
- return {
- primary: {
- docker: docker.ok ? 'supported' : 'unavailable',
- gvisor: gvisor ? 'supported' : 'unavailable',
- sbx: sbxPrimary ? 'supported' : 'unavailable',
- },
- query: {
- docker: docker.ok ? 'supported' : 'unavailable',
- gvisor: gvisor ? 'supported' : 'unavailable',
- sbx: sbxQuerySupported ? 'supported' : 'blocked',
- },
- };
-}
-
-function evaluate(primary, query, capabilities) {
- if (capabilities.primary[primary] !== 'supported') {
- return {
- status: 'BLOCKED',
- capability: capabilities.primary[primary],
- phase: 'primary-preflight',
- };
- }
- if (capabilities.query[query] !== 'supported') {
- return {
- status: 'BLOCKED',
- capability: capabilities.query[query],
- phase: 'query-preflight',
- };
- }
- return { status: 'SUPPORTED', capability: 'supported', phase: 'ready' };
-}
-
-function renderMatrix(capabilities) {
- const lines = [
- '## Bounded-query runtime capability matrix',
- '',
- '| Primary agent | Query sandbox | Result | Primary capability | Query capability | Gate |',
- '|---|---|---|---|---|---|',
- ];
- for (const primary of BACKENDS) {
- for (const query of BACKENDS) {
- const result = evaluate(primary, query, capabilities);
- lines.push(
- `| ${primary} | ${query} | ${result.status} | ${capabilities.primary[primary]} | ` +
- `${capabilities.query[query]} | ${result.phase} |`,
- );
- }
- }
- lines.push(
- '',
- '> BLOCKED is an expected fail-closed security result, not runtime success. No fallback is attempted.',
- );
- return `${lines.join('\n')}\n`;
-}
-
-function main() {
- const capabilities = collectCapabilities();
- const report = renderMatrix(capabilities);
- process.stdout.write(report);
- if (process.env.GITHUB_STEP_SUMMARY) {
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
- }
-
- const requiredIndex = process.argv.indexOf('--require');
- if (requiredIndex !== -1) {
- const requirement = process.argv[requiredIndex + 1] || '';
- const [primary, query] = requirement.split('/');
- if (!BACKENDS.includes(primary) || !BACKENDS.includes(query)) {
- throw new Error(`Invalid --require combination: ${requirement}`);
- }
- const result = evaluate(primary, query, capabilities);
- if (result.status !== 'SUPPORTED') {
- throw new Error(`Required runtime combination ${requirement} is ${result.status} at ${result.phase}`);
- }
- }
-}
-
-if (require.main === module) {
- try {
- main();
- } catch (error) {
- process.stderr.write(`${error.message}\n`);
- process.exitCode = 1;
- }
-}
-
-module.exports = { collectCapabilities, evaluate, renderMatrix };
diff --git a/scripts/ci/report-bounded-query-runtime-matrix.test.ts b/scripts/ci/report-bounded-query-runtime-matrix.test.ts
deleted file mode 100644
index 62d3f09f6..000000000
--- a/scripts/ci/report-bounded-query-runtime-matrix.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as path from 'path';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const { collectCapabilities, evaluate, renderMatrix } = require(
- path.join(__dirname, 'report-bounded-query-runtime-matrix.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-describe('bounded-query runtime capability report', () => {
- it('reports all nine combinations and preserves the sbx query security block', () => {
- const capabilities = collectCapabilities((command: string, args: string[]) => {
- if (command === 'docker') {
- return { ok: true, stdout: '{"runc":{},"runsc":{}}' };
- }
- if (command === 'sbx') {
- expect(args).toEqual(['ls']);
- return { ok: true, stdout: 'Docker Sandboxes v0.37.1' };
- }
- if (args.includes('sbx-capability-probe.js')) {
- return { ok: false, stdout: '{"supported":false}' };
- }
- return { ok: false, stdout: '' };
- });
- const report = renderMatrix(capabilities);
- const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line));
- expect(rows).toHaveLength(9);
- expect(report).toContain('| sbx | sbx | BLOCKED | supported | blocked | query-preflight |');
- expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success');
- });
-
- it('never promotes an unavailable primary or query runtime through fallback', () => {
- const capabilities = {
- primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' },
- query: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' },
- };
- expect(evaluate('gvisor', 'docker', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'unavailable',
- phase: 'primary-preflight',
- });
- expect(evaluate('docker', 'gvisor', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'unavailable',
- phase: 'query-preflight',
- });
- expect(evaluate('docker', 'sbx', capabilities)).toEqual({
- status: 'BLOCKED',
- capability: 'blocked',
- phase: 'query-preflight',
- });
- });
-});
diff --git a/scripts/ci/smoke-bounded-agent-enclave.sh b/scripts/ci/smoke-bounded-agent-enclave.sh
deleted file mode 100755
index efa5ebe92..000000000
--- a/scripts/ci/smoke-bounded-agent-enclave.sh
+++ /dev/null
@@ -1,223 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-runtime="${1:-docker}"
-case "$runtime" in
- docker) runtime_args=() ;;
- gvisor)
- if ! docker info --format '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}' |
- grep -qx runsc; then
- echo "BLOCKED: gVisor bounded-agent smoke requires registered runsc"
- exit 0
- fi
- runtime_args=(--runtime runsc)
- ;;
- *)
- echo "BLOCKED: unsupported bounded-agent smoke runtime: $runtime" >&2
- exit 2
- ;;
-esac
-
-if ! docker info >/dev/null 2>&1; then
- echo "BLOCKED: Docker daemon is unavailable"
- exit 0
-fi
-
-image="awf-bounded-agent-smoke:${runtime}"
-run_id="$(printf '%08x%08x' "$$" "$RANDOM")"
-network="awf-bounded-agent-smoke-${run_id}"
-proxy="awf-bounded-agent-smoke-proxy-${run_id}"
-root="$(mktemp -d "${TMPDIR:-/tmp}/awf-bounded-agent-smoke.XXXXXX")"
-seccomp="$(pwd)/containers/bounded-query/query-seccomp.json"
-
-cleanup() {
- docker rm -f "$proxy" >/dev/null 2>&1 || true
- docker network rm "$network" >/dev/null 2>&1 || true
- rm -rf "$root"
-}
-trap cleanup EXIT INT TERM
-
-docker build --quiet --target enclave -t "$image" -f containers/bounded-agent/Dockerfile containers >/dev/null
-docker network create --internal "$network" >/dev/null
-
-mkdir -p "$root/seed"
-printf 'LIVE-SMOKE-MARKER\n' > "$root/seed/SECURITY.md"
-printf 'Does SECURITY.md exist?\n' > "$root/task.txt"
-printf '{"type":"boolean"}\n' > "$root/schema.json"
-: > "$root/out"
-: > "$root/session.jsonl"
-chmod -R a+rX "$root/seed" "$root/task.txt" "$root/schema.json"
-chmod a+rw "$root/out" "$root/session.jsonl"
-
-proxy_program='
-import json
-import time
-from http.server import BaseHTTPRequestHandler, HTTPServer
-class Handler(BaseHTTPRequestHandler):
- def send_json(self, payload, status=200):
- encoded = json.dumps(payload).encode()
- self.send_response(status)
- self.send_header("content-type", "application/json")
- self.send_header("content-length", str(len(encoded)))
- self.end_headers()
- self.wfile.write(encoded)
- def do_GET(self):
- self.send_json({
- "object": "list",
- "data": [{
- "id": "live-smoke",
- "object": "model",
- "created": 0,
- "owned_by": "awf",
- }],
- })
- def do_POST(self):
- length = int(self.headers.get("content-length", "0"))
- request = self.rfile.read(length).decode("utf-8", errors="replace")
- body = json.loads(request)
- tool_names = [
- tool.get("function", {}).get("name")
- for tool in body.get("tools", [])
- ]
- print(json.dumps({
- "path": self.path,
- "model": body.get("model"),
- "messageRoles": [
- message.get("role") for message in body.get("messages", [])
- ],
- "tools": tool_names,
- }), flush=True)
- if "LIVE-SMOKE-MARKER" in request:
- message = {"role": "assistant", "content": "true"}
- finish_reason = "stop"
- elif "view" in tool_names:
- message = {
- "role": "assistant",
- "content": None,
- "tool_calls": [{
- "id": "live-view",
- "type": "function",
- "function": {
- "name": "view",
- "arguments": "{\"path\":\"/awf/seed/SECURITY.md\"}",
- },
- }],
- }
- finish_reason = "tool_calls"
- else:
- self.send_json({"error": {"message": "native view tool was not offered"}}, 400)
- return
- self.send_json({
- "id": "chatcmpl-live-smoke",
- "object": "chat.completion",
- "created": int(time.time()),
- "model": "live-smoke",
- "choices": [{
- "index": 0,
- "message": message,
- "finish_reason": finish_reason,
- }],
- "usage": {
- "prompt_tokens": 1,
- "completion_tokens": 1,
- "total_tokens": 2,
- },
- })
- def log_message(self, *_args):
- pass
-HTTPServer(("0.0.0.0", 10000), Handler).serve_forever()
-'
-docker run -d --name "$proxy" --network "$network" --network-alias api-proxy \
- --read-only --cap-drop ALL --security-opt no-new-privileges:true \
- --entrypoint python3 "$image" -c "$proxy_program" >/dev/null
-
-proxy_ready=false
-for _ in $(seq 1 30); do
- if docker exec "$proxy" python3 -c \
- 'import socket; socket.create_connection(("127.0.0.1",10000),1).close()' >/dev/null 2>&1; then
- proxy_ready=true
- break
- fi
- sleep 1
-done
-if [[ "$proxy_ready" != true ]]; then
- echo "FAIL: bounded-agent fake API proxy did not become ready" >&2
- exit 1
-fi
-proxy_ip="$(
- docker inspect "$proxy" \
- --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
-)"
-if [[ -z "$proxy_ip" ]]; then
- echo "FAIL: bounded-agent fake API proxy has no enclave-network address" >&2
- exit 1
-fi
-
-set +e
-logs="$(
- docker run --rm "${runtime_args[@]}" \
- --name "awf-bounded-agent-smoke-${run_id}" \
- --network "$network" \
- --read-only \
- --user 65534:65534 \
- --cap-drop ALL \
- --security-opt no-new-privileges:true \
- --security-opt "seccomp=${seccomp}" \
- --memory 512m --memory-swap 512m --cpus 1 --pids-limit 128 \
- --ulimit fsize=33554432 --ulimit nofile=1024:1024 \
- --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
- --tmpfs /agent:rw,nosuid,nodev,size=64m,uid=65534,gid=65534,mode=0700 \
- --hostname bounded-agent \
- --workdir /awf/seed \
- -v "$root/seed:/awf/seed:ro" \
- -v "$root/task.txt:/awf/task.txt:ro" \
- -v "$root/schema.json:/awf/schema.json:ro" \
- -v "$root/out:/agent/out:rw" \
- -v "$root/session.jsonl:/agent/session.jsonl:rw" \
- -e AWF_BOUNDED_AGENT_ENGINE=copilot \
- -e HOME=/agent/home \
- -e COPILOT_HOME=/agent/copilot \
- -e COPILOT_OFFLINE=true \
- -e COPILOT_GITHUB_TOKEN=****** \
- -e COPILOT_TOKEN=****** \
- -e COPILOT_API_URL="http://${proxy_ip}:10000" \
- -e COPILOT_PROVIDER_BASE_URL="http://${proxy_ip}:10000" \
- -e COPILOT_MODEL=live-smoke \
- -e AWF_BOUNDED_AGENT_API_ENDPOINT="http://${proxy_ip}:10000" \
- -e AWF_BOUNDED_AGENT_PROFILE=openai \
- -e AWF_BOUNDED_AGENT_MODEL=live-smoke \
- -e AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=2 \
- -e AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=64 \
- -e AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=64 \
- -e AWF_BOUNDED_AGENT_DEADLINE_SECONDS=30 \
- -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONUNBUFFERED=1 \
- --entrypoint /usr/local/bin/run-bounded-agent \
- "$image" 2>&1
-)"
-status=$?
-set -e
-if [[ $status -ne 0 || -n "$logs" || "$(cat "$root/out")" != "true" ]]; then
- docker logs "$proxy" >&2 || true
- cat "$root/session.jsonl" >&2 || true
- echo "FAIL: $runtime enclave did not produce one silent canonical result" \
- "(status=$status, streamBytes=${#logs}, resultBytes=$(wc -c < "$root/out"))" >&2
- exit 1
-fi
-
-docker run --rm "${runtime_args[@]}" --network "$network" --entrypoint python3 "$image" -c '
-import socket, sys, urllib.request
-socket.create_connection((sys.argv[1], 10000), 2).close()
-try:
- urllib.request.urlopen("https://example.com", timeout=2)
-except Exception:
- sys.exit(0)
-sys.exit(1)
-' "$proxy_ip"
-
-peers="$(docker network inspect "$network" --format '{{len .Containers}}')"
-if [[ "$peers" != "1" ]]; then
- echo "FAIL: API-proxy-only network retained unexpected peers: $peers" >&2
- exit 1
-fi
-
-echo "SUPPORTED: $runtime bounded-agent enclave live smoke passed"
diff --git a/scripts/ci/smoke-bounded-agents-workflow.test.ts b/scripts/ci/smoke-bounded-agents-workflow.test.ts
deleted file mode 100644
index 1acf4c1ab..000000000
--- a/scripts/ci/smoke-bounded-agents-workflow.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { execFileSync } from 'child_process';
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import { load } from 'js-yaml';
-
-const workflowsDir = path.resolve(__dirname, '../../.github/workflows');
-
-const workflows = [
- {
- source: 'smoke-bounded-agents.md',
- lock: 'smoke-bounded-agents.lock.yml',
- runtime: 'docker',
- },
- {
- source: 'smoke-bounded-agents-gvisor.md',
- lock: 'smoke-bounded-agents-gvisor.lock.yml',
- runtime: 'gvisor',
- },
-];
-
-describe.each(workflows)('$source', ({ source, lock, runtime }) => {
- it('validates successful invocations using protected audit and runtime telemetry', () => {
- const sourceText = fs.readFileSync(path.join(workflowsDir, source), 'utf-8');
-
- expect(sourceText).not.toContain('invocations[0].outcome');
- expect(sourceText).toContain('record.kind === "invocation"');
- expect(sourceText).toContain('record.category === "success"');
- });
-
- it('configures bounded agents only after gh-aw generates the AWF config', () => {
- const sourceText = fs.readFileSync(path.join(workflowsDir, source), 'utf-8');
- const lockText = fs.readFileSync(path.join(workflowsDir, lock), 'utf-8');
-
- expect(sourceText).not.toContain('RUNNER_TEMP}/gh-aw/awf-config.json');
- expect(sourceText).toContain(
- `configure-bounded-agent.cjs" "$config_path" ${runtime}`,
- );
-
- const configGeneration = lockText.indexOf(
- `> "\${RUNNER_TEMP}/gh-aw/awf-config.json"`,
- );
- const wrapperInvocation = lockText.indexOf(
- `configure-bounded-agent.cjs\\" \\"$config_path\\" ${runtime}`,
- );
- const awfInvocation = lockText.indexOf(
- 'awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json"',
- );
-
- expect(configGeneration).toBeGreaterThan(-1);
- expect(wrapperInvocation).toBeGreaterThan(-1);
- expect(awfInvocation).toBeGreaterThan(configGeneration);
- });
-
- it('patches the generated config immediately before invoking AWF', () => {
- const lockText = fs.readFileSync(path.join(workflowsDir, lock), 'utf-8');
- const workflow = load(lockText) as {
- jobs: Record }>;
- };
- const setup = workflow.jobs.agent.steps?.find(
- (step) => step.name === 'Replace release bootstrap with current AWF build',
- )?.run;
- expect(setup).toBeDefined();
-
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-smoke-'));
- const workspace = path.join(tempDir, 'workspace');
- const configPath = path.join(tempDir, 'awf-config.json');
- fs.mkdirSync(path.join(workspace, 'dist'), { recursive: true });
- fs.writeFileSync(
- path.join(workspace, 'dist/cli.js'),
- [
- 'const fs = require("fs");',
- 'const index = process.argv.indexOf("--config");',
- 'process.stdout.write(fs.readFileSync(process.argv[index + 1], "utf8"));',
- ].join('\n'),
- );
-
- const env = {
- ...process.env,
- HOME: tempDir,
- GITHUB_WORKSPACE: workspace,
- };
-
- try {
- execFileSync('bash', ['-c', setup ?? 'exit 1'], { env });
- fs.writeFileSync(configPath, '{"apiProxy":{"enabled":true}}\n');
- const output = execFileSync(
- path.join(tempDir, '.local/bin/awf'),
- ['--config', configPath],
- { encoding: 'utf-8', env },
- );
- const config = JSON.parse(output) as {
- apiProxy: { enabled: boolean; targets?: { copilot?: object } };
- boundedAgents: { enabled: boolean; engine: string; runtime: string };
- };
-
- expect(config.apiProxy.enabled).toBe(true);
- expect(config.apiProxy.targets?.copilot).toEqual({});
- expect(config.boundedAgents).toMatchObject({ enabled: true, engine: 'copilot', runtime });
- } finally {
- fs.rmSync(tempDir, { recursive: true, force: true });
- }
- });
-});
diff --git a/scripts/ci/smoke-bounded-queries.sh b/scripts/ci/smoke-bounded-queries.sh
deleted file mode 100755
index 30967caef..000000000
--- a/scripts/ci/smoke-bounded-queries.sh
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-readonly TARGET_REPO="github/gh-aw"
-readonly ARRAY_SCHEMA='{"type":"array","items":{"type":"boolean"},"length":28}'
-readonly BOOLEAN_SCHEMA='{"type":"boolean"}'
-readonly QUERY_RUNTIME="${SMOKE_QUERY_RUNTIME:-docker}"
-
-fail() {
- echo "::error::$*" >&2
- exit 1
-}
-
-run_inside_agent() {
- command -v bounded-query >/dev/null || fail "bounded-query is not installed"
- [[ "${AWF_BOUNDED_QUERY_REPOS:-}" == "$TARGET_REPO" ]] ||
- fail "unexpected AWF_BOUNDED_QUERY_REPOS: ${AWF_BOUNDED_QUERY_REPOS:-}"
- [[ -z "${GH_TOKEN:-}" && -z "${GITHUB_TOKEN:-}" ]] ||
- fail "staging credentials reached the agent environment"
- [[ -n "${AWF_BOUNDED_QUERY_SOCKET:-}" && -S "$AWF_BOUNDED_QUERY_SOCKET" ]] ||
- fail "bounded-query broker socket is unavailable"
-
- local schema expected_sequence result_kind
- if [[ "${SMOKE_RUNTIME_ONLY:-false}" == "true" ]]; then
- [[ "${SMOKE_SENSITIVITY:-}" == "internal" ]] ||
- fail "runtime-only smoke requires internal sensitivity"
- schema="$BOOLEAN_SCHEMA"
- expected_sequence="ok"
- result_kind="boolean"
- else
- case "${SMOKE_SENSITIVITY:-}" in
- public)
- schema="$ARRAY_SCHEMA"
- expected_sequence="ok ok ok"
- result_kind="array"
- ;;
- internal)
- schema="$ARRAY_SCHEMA"
- expected_sequence="ok ok error"
- result_kind="array"
- ;;
- confidential)
- schema="$BOOLEAN_SCHEMA"
- expected_sequence="ok error error"
- result_kind="boolean"
- ;;
- sealed)
- schema="$BOOLEAN_SCHEMA"
- expected_sequence="error error error"
- result_kind="boolean"
- ;;
- *)
- fail "unsupported sensitivity: ${SMOKE_SENSITIVITY:-}"
- ;;
- esac
- fi
-
- local -a expected
- read -r -a expected <<< "$expected_sequence"
-
- local attempt response actual
- for attempt in "${!expected[@]}"; do
- if [[ "$result_kind" == "array" ]]; then
- response="$(
- bounded-query --repo "$TARGET_REPO" --schema "$schema" <<'PY'
-import json
-from pathlib import Path
-
-go_mod_exists = Path("/query/repo/go.mod").is_file()
-Path("/query/out").write_text(json.dumps([go_mod_exists] * 28))
-PY
- )"
- else
- response="$(
- bounded-query --repo "$TARGET_REPO" --schema "$schema" <<'PY'
-import json
-from pathlib import Path
-
-go_mod_exists = Path("/query/repo/go.mod").is_file()
-Path("/query/out").write_text(json.dumps(go_mod_exists))
-PY
- )"
- fi
-
- actual="$(
- python3 - "$response" "$result_kind" <<'PY'
-import json
-import sys
-
-payload = json.loads(sys.argv[1])
-result_kind = sys.argv[2]
-status = payload.get("status")
-
-if status == "ok":
- result = payload.get("result")
- if result_kind == "boolean" and result is not True:
- raise SystemExit("boolean query did not confirm github/gh-aw/go.mod")
- if result_kind == "array" and result != [True] * 28:
- raise SystemExit("bounded array query did not confirm github/gh-aw/go.mod")
-elif status == "error":
- if payload != {"status": "error"}:
- raise SystemExit("error response was not canonical")
-else:
- raise SystemExit(f"unexpected bounded-query response: {payload!r}")
-
-print(status)
-PY
- )"
-
- [[ "$actual" == "${expected[$attempt]}" ]] ||
- fail "${SMOKE_SENSITIVITY} attempt $((attempt + 1)): expected ${expected[$attempt]}, got $actual ($response)"
- echo "${SMOKE_SENSITIVITY} attempt $((attempt + 1)): $actual"
- done
-
- echo "${SMOKE_SENSITIVITY}: PASS"
-}
-
-run_on_host() {
- command -v awf >/dev/null || fail "awf is not installed"
- [[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]] ||
- fail "GH_TOKEN or GITHUB_TOKEN is required for trusted repository staging"
- [[ "$QUERY_RUNTIME" == "docker" || "$QUERY_RUNTIME" == "gvisor" || "$QUERY_RUNTIME" == "sbx" ]] ||
- fail "unsupported query runtime: $QUERY_RUNTIME"
-
- local root
- root="${RUNNER_TEMP:-/tmp}/smoke-bounded-queries-${QUERY_RUNTIME}-${GITHUB_RUN_ID:-local}"
- mkdir -p "$root"
-
- if [[ "${SMOKE_EXPECT_BLOCKED:-false}" == "true" ]]; then
- [[ "$QUERY_RUNTIME" == "sbx" ]] ||
- fail "expected-blocked smoke is only valid for the sbx query runtime"
- local blocked_config blocked_log blocked_status
- blocked_config="$root/blocked.json"
- blocked_log="$root/blocked.log"
- cat > "$blocked_config" <"$blocked_log" 2>&1
- blocked_status=$?
- set -e
-
- [[ "$blocked_status" -ne 0 ]] ||
- fail "$QUERY_RUNTIME unexpectedly passed mandatory bounded-query preflight"
- grep -F 'boundedQueries.runtime "sbx" is blocked' "$blocked_log" >/dev/null ||
- fail "$QUERY_RUNTIME did not report the expected security block"
- grep -F 'AWF will not launch a query VM and will never fall back to Docker or gVisor' "$blocked_log" >/dev/null ||
- fail "$QUERY_RUNTIME did not confirm that fallback is disabled"
- echo "$QUERY_RUNTIME: expected fail-closed preflight PASS"
- return
- fi
-
- local sensitivity config work_dir audit_dir audit_log workspace runtime_only
- workspace="${GITHUB_WORKSPACE:-$(pwd)}"
- runtime_only=false
- local -a sensitivities=(public internal confidential sealed)
- if [[ "$QUERY_RUNTIME" == "gvisor" ]]; then
- runtime_only=true
- sensitivities=(internal)
- fi
- for sensitivity in "${sensitivities[@]}"; do
- config="$root/$sensitivity.json"
- work_dir="$root/$sensitivity-work"
- audit_dir="$root/$sensitivity-audit"
-
- cat > "$config" <> = [];
- return {
- records,
- invocation: (record: Record) => records.push({ kind: 'invocation', ...record }),
- failure: (invocationId: string, reason: string, detail?: string) =>
- records.push({ kind: 'failure', invocationId, reason, detail }),
- lifecycle: (event: string, detail?: unknown) => records.push({ kind: 'lifecycle', event, detail }),
- };
-}
-
-function makeClock() {
- let now = 0;
- return {
- nowMs: () => now,
- sleep: (ms: number) => {
- now += ms;
- return Promise.resolve();
- },
- advance: (ms: number) => {
- now += ms;
- },
- };
-}
-
-interface WorkspaceStub {
- created: string[];
- destroyed: string[];
- output: string | undefined;
- createInvocationWorkspace: (params: Record) => Record;
- readEnclaveOutput: (outPath: string, maxOutputBytes: number) => string | undefined;
- destroyInvocationWorkspace: (workDir: string, invocationId: string) => void;
- preserveInvocationSession: (sessionLogPath: string, auditDir: string, invocationId: string) => boolean;
-}
-
-function makeWorkspace(output: string | undefined = 'true'): WorkspaceStub {
- const stub: WorkspaceStub = {
- created: [],
- destroyed: [],
- output,
- createInvocationWorkspace: (params) => {
- stub.created.push(params.invocationId as string);
- return {
- outPath: `/srv/awf/work/${params.invocationId}/out`,
- sessionLogPath: `/srv/awf/work/${params.invocationId}/session.jsonl`,
- };
- },
- readEnclaveOutput: () => stub.output,
- destroyInvocationWorkspace: (_workDir, invocationId) => {
- stub.destroyed.push(invocationId);
- },
- preserveInvocationSession: () => true,
- };
- return stub;
-}
-
-/** Simulates a missing/oversized/non-regular result file. */
-function makeMissingOutputWorkspace(): WorkspaceStub {
- const stub = makeWorkspace();
- stub.output = undefined;
- return stub;
-}
-
-function makeRunner(overrides: Record = {}) {
- return {
- launches: [] as Array>,
- assertAvailable: async () => undefined,
- reconcileRun: async () => undefined,
- runEnclaveContainer: async function (params: Record) {
- (this.launches as Array>).push(params);
- return { exitCode: 0, timedOut: false };
- },
- ...overrides,
- };
-}
-
-function seedMap(sensitivity = 'internal'): Map {
- return new Map([['octo/alpha', { seedId: SEED_ID, sensitivity }]]);
-}
-
-function request(overrides: Record = {}): Record {
- return { privateRepo: 'octo/alpha', schema: booleanSchema, task: 'is it true?', ...overrides };
-}
-
-async function invoke(broker: any, req: unknown): Promise {
- let response = '';
- await broker.handle(req, (json: string) => {
- response = json;
- });
- return response;
-}
-
-describe('bounded-agent broker', () => {
- it('returns a canonical success envelope for a conforming result', async () => {
- const workspace = makeWorkspace('true');
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace,
- clock: makeClock(),
- });
-
- expect(await invoke(broker, request())).toBe('{"status":"ok","result":true}');
- });
-
- it('collapses every failure class to the identical canonical error', async () => {
- const cases: Array<[string, Record]> = [
- ['invalid-request', { workspace: makeWorkspace('true'), req: request({ image: 'evil' }) }],
- ['repo-not-allowed', { workspace: makeWorkspace('true'), req: request({ privateRepo: 'octo/beta' }) }],
- ['nonconformant-output', { workspace: makeWorkspace('"nope"'), req: request() }],
- ['unreadable-output', { workspace: makeMissingOutputWorkspace(), req: request() }],
- ];
-
- for (const [, params] of cases) {
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace: params.workspace as WorkspaceStub,
- clock: makeClock(),
- });
- expect(await invoke(broker, params.req)).toBe(CANONICAL_ERROR_JSON);
- }
- });
-
- it('maps a timed-out enclave to the canonical error', async () => {
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 0, timedOut: true }) }),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- });
-
- it('maps a non-zero enclave exit to the canonical error without exposing it', async () => {
- const audit = makeAudit();
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit,
- runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 42, timedOut: false }) }),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(audit.records).toEqual([
- expect.objectContaining({ kind: 'failure', reason: 'non-zero-exit' }),
- ]);
- expect(audit.records[0]).not.toHaveProperty('exitCode');
- expect(audit.records[0].detail).toBeUndefined();
- });
-
- it.each([
- [10, 'enclave-configuration-invalid'],
- [11, 'enclave-input-invalid'],
- [20, 'enclave-deadline-exceeded'],
- [21, 'enclave-provider-http-error'],
- [22, 'enclave-provider-transport-error'],
- [23, 'enclave-provider-response-invalid'],
- [30, 'enclave-result-write-failed'],
- [31, 'enclave-model-loop-exhausted'],
- ])('records fixed diagnostic category for enclave exit %i', async (exitCode, reason) => {
- const audit = makeAudit();
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit,
- runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode, timedOut: false }) }),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
-
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(audit.records).toEqual([
- expect.objectContaining({ kind: 'failure', reason }),
- ]);
- expect(audit.records[0]).not.toHaveProperty('exitCode');
- expect(audit.records[0].detail).toBeUndefined();
- });
-
- it('debits the sensitivity budget before any workspace or container exists', async () => {
- const workspace = makeWorkspace('true');
- const runner = makeRunner();
- // `sealed` is a 0-bit budget, so even the cheapest schema is unaffordable.
- const broker = createBroker({
- config,
- seedMap: seedMap('sealed'),
- runId: RUN_ID,
- audit: makeAudit(),
- runner,
- workspace,
- clock: makeClock(),
- });
-
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(workspace.created).toEqual([]);
- expect(runner.launches).toEqual([]);
- });
-
- it('charges the status and timing channels, not just the schema payload', async () => {
- const ledger = createLedger(seedMap('confidential'));
- const broker = createBroker({
- config,
- seedMap: seedMap('confidential'),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- ledger,
- });
-
- // confidential = 8 bits/run; a boolean costs 1 (status) + 1 (payload) + 3 (timing) = 5.
- await invoke(broker, request());
- expect(ledger.remainingBits('octo/alpha')).toBe(3);
- // The second identical request no longer fits.
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(ledger.remainingBits('octo/alpha')).toBe(3);
- });
-
- it('never refunds a committed charge, even when the enclave fails', async () => {
- const ledger = createLedger(seedMap('confidential'));
- const broker = createBroker({
- config,
- seedMap: seedMap('confidential'),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 1, timedOut: false }) }),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- ledger,
- });
-
- await invoke(broker, request());
- expect(ledger.remainingBits('octo/alpha')).toBe(3);
- });
-
- it('keeps a ledger separate from any other bounded subsystem', async () => {
- const agentLedger = createLedger(seedMap('confidential'));
- const queryLedger = createLedger(seedMap('confidential'));
- const broker = createBroker({
- config,
- seedMap: seedMap('confidential'),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- ledger: agentLedger,
- });
-
- await invoke(broker, request());
- expect(agentLedger.remainingBits('octo/alpha')).toBe(3);
- // A sibling subsystem's ledger is untouched.
- expect(queryLedger.remainingBits('octo/alpha')).toBe(8);
- });
-
- it('enforces the per-run invocation cap on every response, including rejections', async () => {
- const runner = makeRunner();
- const broker = createBroker({
- config: { ...config, maxInvocations: 2 },
- seedMap: seedMap('public'),
- runId: RUN_ID,
- audit: makeAudit(),
- runner,
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
-
- // A rejected request still consumes one unit.
- expect(await invoke(broker, request({ image: 'evil' }))).toBe(CANONICAL_ERROR_JSON);
- expect(await invoke(broker, request())).toBe('{"status":"ok","result":true}');
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(runner.launches).toHaveLength(1);
- });
-
- it('destroys the workspace before responding', async () => {
- const workspace = makeWorkspace('true');
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace,
- clock: makeClock(),
- });
-
- await invoke(broker, request());
- expect(workspace.destroyed).toEqual(workspace.created);
- });
-
- it('fails closed when workspace teardown fails', async () => {
- const workspace = makeWorkspace('true');
- workspace.destroyInvocationWorkspace = () => {
- throw new Error('EBUSY');
- };
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner(),
- workspace,
- clock: makeClock(),
- });
-
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- });
-
- it('holds the response until a fixed timing bucket boundary', async () => {
- const clock = makeClock();
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner: makeRunner({
- runEnclaveContainer: async () => {
- clock.advance(37);
- return { exitCode: 0, timedOut: false };
- },
- }),
- workspace: makeWorkspace('true'),
- clock,
- });
-
- await invoke(broker, request());
- // 37ms of work is padded to the 100ms bucket.
- expect(clock.nowMs()).toBe(100);
- });
-
- it('records the sensitivity class and charge but never the repository or task', async () => {
- const audit = makeAudit();
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit,
- runner: makeRunner(),
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
-
- await invoke(broker, request({ task: 'SECRET-TASK-MARKER' }));
- const serialized = JSON.stringify(audit.records);
- expect(serialized).toContain('"sensitivity":"internal"');
- expect(serialized).not.toContain('octo/alpha');
- expect(serialized).not.toContain('SECRET-TASK-MARKER');
- expect(serialized).not.toContain('/srv/awf/work');
- });
-
- it('stops admitting invocations after close()', async () => {
- const runner = makeRunner();
- const broker = createBroker({
- config,
- seedMap: seedMap(),
- runId: RUN_ID,
- audit: makeAudit(),
- runner,
- workspace: makeWorkspace('true'),
- clock: makeClock(),
- });
-
- broker.close();
- expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON);
- expect(runner.launches).toEqual([]);
- });
-});
-
-describe('bounded-agent protected audit', () => {
- it('writes to a file distinct from the bounded-query audit trail', () => {
- expect(BOUNDED_AGENT_AUDIT_FILENAME).toBe('bounded-agent.jsonl');
- });
-});
-
-describe('bounded-agent enclave container spec', () => {
- const spec = deriveEnclaveContainerSpec({
- config,
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- });
- const args: string[] = [...spec.launchArgs];
-
- const flagValues = (flag: string): string[] =>
- args.reduce((acc, value, index) => {
- if (value === flag && index + 1 < args.length) acc.push(args[index + 1]);
- return acc;
- }, []);
-
- it('joins only the dedicated bounded-agent network', () => {
- expect(flagValues('--network')).toEqual(['awf-bounded-agent']);
- expect(args).not.toContain('awf-net');
- expect(args).not.toContain('awf-ext');
- });
-
- it('mounts the repository read-only and never writable', () => {
- expect(flagValues('-v')).toContain(`/var/tmp/private/seeds/${SEED_ID}:/awf/seed:ro`);
- expect(args).toContain('--read-only');
- });
-
- it('mounts inputs read-only and result/session files read-write', () => {
- const volumes = flagValues('-v');
- expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/task.txt:/awf/task.txt:ro`);
- expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/schema.json:/awf/schema.json:ro`);
- expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/out:/agent/out:rw`);
- expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/session.jsonl:/agent/session.jsonl:rw`);
- expect(volumes).toHaveLength(5);
- });
-
- it('never mounts the Docker socket, the workspace, or host state into the enclave', () => {
- const volumes = flagValues('-v').join(' ');
- expect(volumes).not.toContain('docker.sock');
- expect(volumes).not.toContain('/host');
- expect(volumes).not.toContain('seed-map.json');
- expect(args).not.toContain('--privileged');
- });
-
- it('applies bounded tmpfs mounts for work, result, and /tmp', () => {
- const tmpfs = flagValues('--tmpfs');
- expect(tmpfs).toContain('/tmp:rw,noexec,nosuid,nodev,size=64m');
- expect(tmpfs).toContain('/agent:rw,nosuid,nodev,size=64m,uid=65534,gid=65534,mode=0700');
- });
-
- it('runs as a fixed non-root uid/gid with all capabilities dropped', () => {
- expect(flagValues('--user')).toEqual(['65534:65534']);
- expect(flagValues('--cap-drop')).toEqual(['ALL']);
- expect(flagValues('--security-opt')).toEqual([
- 'no-new-privileges:true',
- 'seccomp=/opt/awf/enclave-seccomp.json',
- ]);
- });
-
- it('bounds memory, cpu, pids, and file size', () => {
- expect(flagValues('--memory')).toEqual(['512m']);
- expect(flagValues('--memory-swap')).toEqual(['512m']);
- expect(flagValues('--cpus')).toEqual(['1']);
- expect(flagValues('--pids-limit')).toEqual(['128']);
- expect(flagValues('--ulimit')).toEqual([`fsize=${32 * 1024 * 1024}`, 'nofile=1024:1024']);
- });
-
- it('never pulls and always uses a fresh uniquely named labelled container', () => {
- expect(flagValues('--pull')).toEqual(['never']);
- expect(spec.containerName).toBe(`awf-bounded-agent-${RUN_ID.slice(0, 12)}-${'c'.repeat(24)}`);
- expect(flagValues('--label')).toEqual([
- `awf.bounded-agent.run=${RUN_ID}`,
- `awf.bounded-agent.invocation=${'c'.repeat(24)}`,
- ]);
- });
-
- it('passes only the fixed trusted enclave environment', () => {
- expect(flagValues('--env').sort()).toEqual([
- 'AWF_BOUNDED_AGENT_API_ENDPOINT=http://172.31.0.30:10002',
- 'AWF_BOUNDED_AGENT_DEADLINE_SECONDS=120',
- 'AWF_BOUNDED_AGENT_ENGINE=copilot',
- 'AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=8',
- 'AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=1024',
- 'AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=8192',
- 'AWF_BOUNDED_AGENT_MODEL=gpt-4o-mini',
- 'AWF_BOUNDED_AGENT_PROFILE=openai',
- 'COPILOT_API_URL=http://172.31.0.30:10002',
- 'COPILOT_GITHUB_TOKEN=******',
- 'COPILOT_HOME=/agent/copilot',
- 'COPILOT_MODEL=gpt-4o-mini',
- 'COPILOT_OFFLINE=true',
- 'COPILOT_PROVIDER_BASE_URL=http://172.31.0.30:10002',
- 'COPILOT_TOKEN=******',
- 'HOME=/agent/home',
- 'PYTHONDONTWRITEBYTECODE=1',
- 'PYTHONUNBUFFERED=1',
- ]);
- });
-
- it('never leaks a real credential or general proxy setting into the enclave environment', () => {
- for (const entry of flagValues('--env')) {
- const [name, value] = entry.split('=');
- if (/_TOKEN$/.test(name)) expect(value).toBe('******');
- expect(entry).not.toMatch(/github_pat_|gh[opsu]_|sk-|AUTHORIZATION|SECRET|CREDENTIAL/i);
- expect(name).not.toMatch(/^(?:HTTP|HTTPS|NO)_PROXY$/i);
- }
- });
-
- it('uses the fixed AWF-authored entrypoint', () => {
- expect(args.slice(-3)).toEqual([
- '--entrypoint',
- '/usr/local/bin/run-bounded-agent',
- 'ghcr.io/github/gh-aw-firewall/bounded-agent:latest',
- ]);
- });
-
- it('adds --runtime runsc only for the gVisor backend', () => {
- expect(args).not.toContain('--runtime');
- const gvisorArgs: string[] = [
- ...deriveEnclaveContainerSpec({
- config,
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- runtimeName: 'runsc',
- }).launchArgs,
- ];
- expect(gvisorArgs).toContain('--runtime');
- expect(gvisorArgs[gvisorArgs.indexOf('--runtime') + 1]).toBe('runsc');
- });
-
- it('rejects any other OCI runtime', () => {
- expect(() =>
- deriveEnclaveContainerSpec({
- config,
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- runtimeName: 'kata',
- }),
- ).toThrow(/Unsupported OCI runtime/);
- });
-
- it('rejects identifiers that are not broker-generated', () => {
- for (const bad of ['../escape', 'UPPER', 'has space', '']) {
- expect(() =>
- deriveEnclaveContainerSpec({ config, runId: RUN_ID, invocationId: bad, seedId: SEED_ID }),
- ).toThrow(/broker-generated identifier/);
- }
- expect(() =>
- deriveEnclaveContainerSpec({
- config,
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: '../../etc',
- }),
- ).toThrow(/AWF-generated seed identifier/);
- });
-
- it('freezes the argument vector', () => {
- expect(Object.isFrozen(spec.launchArgs)).toBe(true);
- });
-});
-
-describe('bounded-agent enclave runner selection', () => {
- const dockerStub = (results: Record) => ({
- calls: [] as string[][],
- runDocker: async function (args: string[]) {
- (this.calls as string[][]).push(args);
- const key = `${args[0]} ${args[1] ?? ''}`.trim();
- const result = results[key] ?? results[args[0]] ?? { exitCode: 0 };
- return { exitCode: result.exitCode, stdout: result.stdout ?? '', stderr: '', timedOut: false };
- },
- });
-
- it('selects the Docker runner for the docker backend', () => {
- expect(createEnclaveRunner(config)).toBeInstanceOf(DockerEnclaveRunner);
- });
-
- it('selects the gVisor runner for the gvisor backend', () => {
- expect(createEnclaveRunner({ ...config, backend: 'gvisor' })).toBeInstanceOf(GvisorEnclaveRunner);
- });
-
- it('selects the sbx runner for the sbx backend, which fails closed on assertAvailable', () => {
- const runner = createEnclaveRunner({ ...config, backend: 'sbx' });
- expect(runner).toBeInstanceOf(SbxEnclaveRunner);
- });
-
- it('fails closed for any other backend, not sbx', () => {
- expect(() => createEnclaveRunner({ ...config, backend: 'firecracker' })).toThrow(/Unsupported/);
- });
-
- it('requires the enclave image and the dedicated network to already exist', async () => {
- const missingImage = dockerStub({ 'image inspect': { exitCode: 1 } });
- await expect(
- new DockerEnclaveRunner(config, { docker: missingImage }).assertAvailable(),
- ).rejects.toThrow(/image is not available/);
-
- const missingNetwork = dockerStub({
- 'image inspect': { exitCode: 0 },
- 'network inspect': { exitCode: 1 },
- });
- await expect(
- new DockerEnclaveRunner(config, { docker: missingNetwork }).assertAvailable(),
- ).rejects.toThrow(/bounded-agent network is not available/);
- });
-
- it('requires an exactly registered runsc for the gVisor runner', async () => {
- const withoutRunsc = dockerStub({
- 'image inspect': { exitCode: 0 },
- 'network inspect': { exitCode: 0 },
- 'info --format': { exitCode: 0, stdout: 'runc\n' },
- info: { exitCode: 0, stdout: 'runc\n' },
- });
- await expect(
- new GvisorEnclaveRunner(config, { docker: withoutRunsc }).assertAvailable(),
- ).rejects.toThrow(/no fallback is permitted/);
-
- const withRunsc = dockerStub({
- 'image inspect': { exitCode: 0 },
- 'network inspect': { exitCode: 0 },
- info: { exitCode: 0, stdout: 'runc\nrunsc\n' },
- });
- await expect(
- new GvisorEnclaveRunner(config, { docker: withRunsc }).assertAvailable(),
- ).resolves.toBeUndefined();
- });
-
- it('deterministically removes every container labelled with this run', async () => {
- const docker = dockerStub({ ps: { exitCode: 0, stdout: 'abcdef123456\n' } });
- const runner = new DockerEnclaveRunner(config, { docker });
- await runner.reconcileRun(RUN_ID);
-
- const listed = docker.calls.find((args) => args[0] === 'ps');
- expect(listed).toEqual(['ps', '-aq', '--filter', `label=awf.bounded-agent.run=${RUN_ID}`]);
- expect(docker.calls).toContainEqual(['rm', '-f', 'abcdef123456']);
- });
-
- it('removes the invocation container before returning and discards its streams', async () => {
- const docker = dockerStub({
- run: { exitCode: 0, stdout: 'CHATTY ENCLAVE OUTPUT' },
- ps: { exitCode: 0, stdout: 'abcdef123456\n' },
- });
- const runner = new DockerEnclaveRunner(config, { docker });
- const result = await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- timeoutMs: 1000,
- });
-
- expect(result).toEqual({ exitCode: 0, timedOut: false });
- expect(JSON.stringify(result)).not.toContain('CHATTY');
- expect(docker.calls.some((args) => args[0] === 'rm')).toBe(true);
- });
-
- it('fails closed when cleanup fails after an interrupted enclave', async () => {
- const docker = {
- runDocker: async (args: string[]) => {
- if (args[0] === 'run') return { exitCode: 0, stdout: '', stderr: '', timedOut: true };
- if (args[0] === 'ps') return { exitCode: 1, stdout: '', stderr: '', timedOut: false };
- return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
- },
- };
- const runner = new DockerEnclaveRunner(config, { docker });
- await expect(
- runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- timeoutMs: 1000,
- }),
- ).rejects.toThrow(/reconcile bounded-agent containers/);
- });
-
- it('fails closed when cleanup fails after a successful enclave', async () => {
- const docker = {
- runDocker: async (args: string[]) => {
- if (args[0] === 'run') return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
- if (args[0] === 'ps') return { exitCode: 1, stdout: '', stderr: '', timedOut: false };
- return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
- },
- };
- const runner = new DockerEnclaveRunner(config, { docker });
- await expect(
- runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: 'c'.repeat(24),
- seedId: SEED_ID,
- timeoutMs: 1000,
- }),
- ).rejects.toThrow(/reconcile bounded-agent containers/);
- });
-});
diff --git a/src/bounded-agent/copilot-enclave.test.ts b/src/bounded-agent/copilot-enclave.test.ts
deleted file mode 100644
index 03a5ec330..000000000
--- a/src/bounded-agent/copilot-enclave.test.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-import { execFile } from 'child_process';
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-
-const repoRoot = path.join(__dirname, '..', '..');
-const entrypoint = path.join(repoRoot, 'containers', 'bounded-agent', 'copilot-entrypoint.py');
-
-describe('native Copilot bounded-agent adapter', () => {
- let root: string;
-
- beforeEach(() => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-copilot-enclave-'));
- });
-
- afterEach(() => {
- fs.rmSync(root, { recursive: true, force: true });
- });
-
- it('runs Copilot with native tools and writes only its finite final response', async () => {
- const seed = path.join(root, 'seed');
- const agent = path.join(root, 'agent');
- fs.mkdirSync(seed);
- fs.mkdirSync(agent);
- fs.writeFileSync(path.join(root, 'task.txt'), 'Does go.mod exist?');
- fs.writeFileSync(path.join(root, 'schema.json'), '{"type":"boolean"}');
- fs.writeFileSync(path.join(agent, 'out'), '');
- fs.writeFileSync(path.join(agent, 'session.jsonl'), '');
-
- const fakeCopilot = path.join(root, 'copilot');
- fs.writeFileSync(fakeCopilot, `#!/bin/sh
-if [ ! -f ${JSON.stringify(path.join(root, 'attempted'))} ]; then
- touch ${JSON.stringify(path.join(root, 'attempted'))}
- kill -ABRT $$
-fi
-printf '%s\\n' "$@" > ${JSON.stringify(path.join(root, 'args.txt'))}
-printf '%s\\n' "$COPILOT_GITHUB_TOKEN" "$COPILOT_API_URL" > ${JSON.stringify(path.join(root, 'env.txt'))}
-printf '● True\\n'
-`);
- fs.chmodSync(fakeCopilot, 0o755);
-
- const driver = `
-import importlib.util, pathlib, sys
-spec = importlib.util.spec_from_file_location("adapter", ${JSON.stringify(entrypoint)})
-module = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(module)
-module.SEED_DIR = pathlib.Path(${JSON.stringify(seed)})
-module.TASK_PATH = pathlib.Path(${JSON.stringify(path.join(root, 'task.txt'))})
-module.SCHEMA_PATH = pathlib.Path(${JSON.stringify(path.join(root, 'schema.json'))})
-module.OUT_PATH = pathlib.Path(${JSON.stringify(path.join(agent, 'out'))})
-module.SESSION_LOG_PATH = pathlib.Path(${JSON.stringify(path.join(agent, 'session.jsonl'))})
-module.AGENT_DIR = pathlib.Path(${JSON.stringify(agent)})
-module.COPILOT_BIN = ${JSON.stringify(fakeCopilot)}
-sys.exit(module.main())
-`;
- const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
- execFile('python3', ['-c', driver], {
- env: {
- PATH: process.env.PATH ?? '/usr/bin:/bin',
- AWF_BOUNDED_AGENT_ENGINE: 'copilot',
- AWF_BOUNDED_AGENT_MODEL: 'gpt-4o-mini',
- AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES: '8192',
- AWF_BOUNDED_AGENT_DEADLINE_SECONDS: '30',
- COPILOT_GITHUB_TOKEN: '******',
- COPILOT_API_URL: 'http://172.31.0.30:10002',
- },
- }, (error, stdout, stderr) => resolve({
- code: typeof (error as { code?: unknown } | null)?.code === 'number'
- ? (error as { code: number }).code : error ? 1 : 0,
- stdout,
- stderr,
- }));
- });
-
- expect(result).toEqual({ code: 0, stdout: '', stderr: '' });
- expect(fs.readFileSync(path.join(agent, 'out'), 'utf8')).toBe('true');
- const args = fs.readFileSync(path.join(root, 'args.txt'), 'utf8');
- expect(args).toContain('--allow-all-tools');
- expect(args).toContain('--allow-all-paths');
- expect(args).toContain('--disable-builtin-mcps');
- expect(args).toMatch(/--stream\noff/);
- expect(args).toContain('built-in shell, bash');
- expect(args).toContain('lowercase JSON literal true or false');
- expect(args).not.toContain('{"type":"boolean"}');
- expect(fs.readFileSync(path.join(root, 'env.txt'), 'utf8')).toBe(
- '******\nhttp://172.31.0.30:10002\n',
- );
- const transcript = fs.readFileSync(path.join(agent, 'session.jsonl'), 'utf8');
- expect(transcript).toContain('"engine":"copilot"');
- expect(transcript).toContain('"event":"engine-retry"');
- expect(transcript).toContain('"signal":6');
- expect(transcript).toContain('"event":"success"');
- expect(transcript).not.toContain('github_pat_');
- });
-
- it('captures bounded redacted diagnostics when Copilot fails silently', async () => {
- const seed = path.join(root, 'seed');
- const agent = path.join(root, 'agent');
- const logs = path.join(agent, 'copilot-logs');
- fs.mkdirSync(seed);
- fs.mkdirSync(agent);
- fs.writeFileSync(path.join(root, 'task.txt'), 'Does go.mod exist?');
- fs.writeFileSync(path.join(root, 'schema.json'), '{"type":"boolean"}');
- fs.writeFileSync(path.join(agent, 'out'), '');
- fs.writeFileSync(path.join(agent, 'session.jsonl'), '');
-
- const fakeCopilot = path.join(root, 'copilot');
- fs.writeFileSync(fakeCopilot, `#!/bin/sh
-mkdir -p ${JSON.stringify(logs)}
-printf 'request failed\\nAuthorization: Bearer github_pat_sensitive\\n' > ${JSON.stringify(path.join(logs, 'process.log'))}
-exit 1
-`);
- fs.chmodSync(fakeCopilot, 0o755);
-
- const driver = `
-import importlib.util, pathlib, sys
-spec = importlib.util.spec_from_file_location("adapter", ${JSON.stringify(entrypoint)})
-module = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(module)
-module.SEED_DIR = pathlib.Path(${JSON.stringify(seed)})
-module.TASK_PATH = pathlib.Path(${JSON.stringify(path.join(root, 'task.txt'))})
-module.SCHEMA_PATH = pathlib.Path(${JSON.stringify(path.join(root, 'schema.json'))})
-module.OUT_PATH = pathlib.Path(${JSON.stringify(path.join(agent, 'out'))})
-module.SESSION_LOG_PATH = pathlib.Path(${JSON.stringify(path.join(agent, 'session.jsonl'))})
-module.AGENT_DIR = pathlib.Path(${JSON.stringify(agent)})
-module.COPILOT_BIN = ${JSON.stringify(fakeCopilot)}
-sys.exit(module.main())
-`;
- const result = await new Promise<{ code: number }>((resolve) => {
- execFile('python3', ['-c', driver], {
- env: {
- PATH: process.env.PATH ?? '/usr/bin:/bin',
- AWF_BOUNDED_AGENT_ENGINE: 'copilot',
- AWF_BOUNDED_AGENT_MODEL: 'gpt-4o-mini',
- AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES: '8192',
- AWF_BOUNDED_AGENT_DEADLINE_SECONDS: '30',
- COPILOT_GITHUB_TOKEN: '******',
- COPILOT_API_URL: 'http://172.31.0.30:10002',
- },
- }, (error) => resolve({
- code: typeof (error as { code?: unknown } | null)?.code === 'number'
- ? (error as { code: number }).code : error ? 1 : 0,
- }));
- });
-
- expect(result.code).toBe(24);
- const transcript = fs.readFileSync(path.join(agent, 'session.jsonl'), 'utf8');
- expect(transcript).toContain('"event":"engine-diagnostics"');
- expect(transcript).toContain('request failed');
- expect(transcript).toContain('Authorization: [REDACTED]');
- expect(transcript).not.toContain('github_pat_sensitive');
- });
-});
diff --git a/src/bounded-agent/ingress-conformance.test.ts b/src/bounded-agent/ingress-conformance.test.ts
deleted file mode 100644
index bc303c6ba..000000000
--- a/src/bounded-agent/ingress-conformance.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import * as fs from 'fs';
-import * as http from 'http';
-import * as net from 'net';
-import * as os from 'os';
-import * as path from 'path';
-import type { AddressInfo } from 'net';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const { createServer, createTcpServer, listenOnSocket, listenOnTcp, MAX_CONNECTIONS } = require(
- path.join(brokerDir, 'server.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-/**
- * TCP ingress conformance tests for the bounded-agent broker, mirroring the
- * coverage bounded queries already have
- * (`src/bounded-query/ingress-conformance.test.ts`): missing/duplicate/wrong
- * capability rejection, one-shot probe retirement, capability-header
- * stripping before framing, byte-identical Unix/TCP canonical responses, body
- * size limits, and connection-limit behavior.
- *
- * Only `server.js`'s existing exports (`createServer`, `createTcpServer`,
- * `listenOnSocket`, `listenOnTcp`, `MAX_CONNECTIONS`) are used — no
- * production surface is widened for these tests.
- */
-
-const CAPABILITY = 'a'.repeat(64);
-const PROBE_CAPABILITY = 'b'.repeat(64);
-const CANONICAL_ERROR = '{"status":"error"}';
-const CANONICAL_OK = '{"status":"ok","result":true}';
-const SCHEMA = Buffer.from('{"type":"boolean"}').toString('base64url');
-const MAX_TASK_BYTES = 64 * 1024;
-
-interface Response {
- status: number | undefined;
- headers: http.IncomingHttpHeaders;
- body: string;
-}
-
-function stableResponse(response: Response) {
- return {
- status: response.status,
- body: response.body,
- contentType: response.headers['content-type'],
- cacheControl: response.headers['cache-control'],
- contentLength: response.headers['content-length'],
- };
-}
-
-function request(options: http.RequestOptions, body = 'do the task'): Promise {
- return new Promise((resolve, reject) => {
- const req = http.request({
- method: 'POST',
- path: '/query',
- ...options,
- headers: {
- 'content-type': 'application/octet-stream',
- 'x-awf-agent-version': '1',
- 'x-awf-repo': 'octo/private',
- 'x-awf-schema-b64': SCHEMA,
- ...options.headers,
- },
- }, (res) => {
- const chunks: Buffer[] = [];
- res.on('data', (chunk) => chunks.push(chunk));
- res.on('end', () => resolve({
- status: res.statusCode,
- headers: res.headers,
- body: Buffer.concat(chunks).toString('utf8'),
- }));
- });
- req.on('error', reject);
- req.end(body);
- });
-}
-
-describe('bounded-agent ingress conformance', () => {
- let root: string;
- let unixServer: http.Server;
- let tcpServer: http.Server;
- let socketPath: string;
- let tcpPort: number;
- let handled: unknown[];
- const audit = {
- failure: jest.fn(),
- lifecycle: jest.fn(),
- };
-
- beforeEach(async () => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-test-'));
- socketPath = path.join(root, 'broker.sock');
- handled = [];
- const broker = {
- handle: (incoming: unknown, respond: (body: string) => void) => {
- handled.push(incoming);
- respond(incoming === undefined ? CANONICAL_ERROR : CANONICAL_OK);
- return Promise.resolve();
- },
- };
- unixServer = createServer({ broker, audit });
- tcpServer = createTcpServer({
- broker,
- audit,
- capabilities: { query: CAPABILITY, probe: PROBE_CAPABILITY },
- });
- await listenOnSocket(unixServer, {
- socketPath,
- socketDir: root,
- socketUid: process.getuid?.() ?? 0,
- socketGid: process.getgid?.() ?? 0,
- }, audit);
- await listenOnTcp(tcpServer, { tcpPort: 0 });
- tcpPort = (tcpServer.address() as AddressInfo).port;
- });
-
- afterEach(async () => {
- await Promise.all([
- new Promise((resolve) => unixServer.close(() => resolve())),
- new Promise((resolve) => tcpServer.close(() => resolve())),
- ]);
- fs.rmSync(root, { recursive: true, force: true });
- jest.clearAllMocks();
- });
-
- const unixRequest = (body?: string) => request({ socketPath }, body);
- const tcpRequest = (body?: string, capability = CAPABILITY) => request({
- host: '127.0.0.1',
- port: tcpPort,
- headers: { 'x-awf-capability': capability },
- }, body);
-
- it('returns byte-identical status, headers, and canonical result bytes across transports', async () => {
- const [unix, tcp] = await Promise.all([unixRequest(), tcpRequest()]);
- expect(stableResponse(tcp)).toEqual(stableResponse(unix));
- expect(stableResponse(tcp)).toEqual(expect.objectContaining({
- status: 200,
- body: CANONICAL_OK,
- contentType: 'application/json',
- cacheControl: 'no-store',
- contentLength: String(Buffer.byteLength(CANONICAL_OK)),
- }));
- expect(handled).toHaveLength(2);
- expect(handled[0]).toEqual(handled[1]);
- expect(handled[0]).not.toHaveProperty('capability');
- });
-
- it('collapses missing, wrong, and duplicated authentication to canonical failure bytes', async () => {
- const missing = request({ host: '127.0.0.1', port: tcpPort });
- const wrong = tcpRequest(undefined, 'c'.repeat(64));
- const duplicated = request({
- host: '127.0.0.1',
- port: tcpPort,
- headers: { 'x-awf-capability': [CAPABILITY, CAPABILITY] },
- });
- const responses = await Promise.all([missing, wrong, duplicated]);
- for (const response of responses) {
- expect(response.status).toBe(200);
- expect(response.body).toBe(CANONICAL_ERROR);
- }
- expect(handled).toHaveLength(0);
- expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected');
- });
-
- it('uses a one-shot probe capability without launching or consuming a request, then permanently retires it', async () => {
- const before = handled.length;
- const first = await tcpRequest('', PROBE_CAPABILITY);
- const second = await tcpRequest('', PROBE_CAPABILITY);
- expect(first.body).toBe(CANONICAL_ERROR);
- expect(second.body).toBe(CANONICAL_ERROR);
- expect(handled.length).toBe(before);
- expect(audit.lifecycle).toHaveBeenCalledWith('sbx-ingress-probe');
- expect(audit.lifecycle).toHaveBeenCalledTimes(1);
- // The second attempt with the same (now-retired) probe capability must be
- // rejected as an ordinary auth failure, not treated as another probe.
- expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected');
- });
-
- it('strips the capability header before handing the request to framing/broker logic', async () => {
- await tcpRequest();
- expect(handled).toHaveLength(1);
- expect(handled[0]).not.toHaveProperty('capability');
- expect(JSON.stringify(handled[0])).not.toContain(CAPABILITY);
- });
-
- it('keeps oversized and parallel request behavior identical across transports', async () => {
- const oversized = 'x'.repeat(MAX_TASK_BYTES + 1);
- const [unixOversized, tcpOversized] = await Promise.all([
- unixRequest(oversized),
- tcpRequest(oversized),
- ]);
- expect(unixOversized.body).toBe(CANONICAL_ERROR);
- expect(stableResponse(tcpOversized)).toEqual(stableResponse(unixOversized));
-
- const results = await Promise.all([
- unixRequest(),
- unixRequest(),
- tcpRequest(),
- tcpRequest(),
- ]);
- expect(results.map((result) => result.body)).toEqual(Array(4).fill(CANONICAL_OK));
- });
-
- it('accepts a task body exactly at the size limit and rejects one byte over it, identically on both transports', async () => {
- const atLimit = 'x'.repeat(MAX_TASK_BYTES);
- const overLimit = 'x'.repeat(MAX_TASK_BYTES + 1);
- const [unixAtLimit, tcpAtLimit] = await Promise.all([unixRequest(atLimit), tcpRequest(atLimit)]);
- expect(unixAtLimit.body).toBe(CANONICAL_OK);
- expect(tcpAtLimit.body).toBe(CANONICAL_OK);
-
- const [unixOverLimit, tcpOverLimit] = await Promise.all([
- unixRequest(overLimit),
- tcpRequest(overLimit),
- ]);
- expect(unixOverLimit.body).toBe(CANONICAL_ERROR);
- expect(tcpOverLimit.body).toBe(CANONICAL_ERROR);
- });
-
- it('does not dispatch broker work for a request that arrives on an over-limit socket', async () => {
- const holders = await Promise.all(Array.from({ length: MAX_CONNECTIONS }, () => new Promise((resolve, reject) => {
- const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => resolve(socket));
- socket.on('error', reject);
- })));
-
- try {
- const rawResponse = await new Promise((resolve, reject) => {
- const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => {
- socket.write([
- 'POST /query HTTP/1.1',
- 'Host: 127.0.0.1',
- `X-AWF-Capability: ${CAPABILITY}`,
- 'Content-Type: application/octet-stream',
- 'X-AWF-Agent-Version: 1',
- 'X-AWF-Repo: octo/private',
- `X-AWF-Schema-B64: ${SCHEMA}`,
- 'Content-Length: 0',
- '',
- '',
- ].join('\r\n'));
- });
- const chunks: Uint8Array[] = [];
- socket.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
- socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
- socket.on('error', reject);
- });
-
- expect(rawResponse).toContain(CANONICAL_ERROR);
- expect(handled).toHaveLength(0);
- expect(audit.failure).toHaveBeenCalledWith('transport', 'connection-limit');
- } finally {
- for (const socket of holders) socket.destroy();
- }
- });
-});
diff --git a/src/bounded-agent/ingress.test.ts b/src/bounded-agent/ingress.test.ts
deleted file mode 100644
index 83088faaf..000000000
--- a/src/bounded-agent/ingress.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import execa from 'execa';
-import type { WrapperConfig } from '../types';
-import { resolveDockerHostGateway } from '../services/host-gateway';
-import {
- removeSbxIngressCapabilityFile,
- resolveSbxIngress,
-} from './ingress';
-import { resolveBoundedAgentPaths } from './paths';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-jest.mock('../services/host-gateway', () => ({
- resolveDockerHostGateway: jest.fn(() => '172.17.0.1'),
-}));
-const mockExeca = execa as unknown as jest.Mock;
-const mockResolveDockerHostGateway = resolveDockerHostGateway as jest.Mock;
-
-describe('sbx bounded-agent ingress resolution', () => {
- let workDir: string;
- let config: WrapperConfig;
-
- beforeEach(() => {
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-resolution-'));
- config = {
- workDir,
- boundedAgentIngressTransport: 'sbx-http',
- } as WrapperConfig;
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.controlDir, { recursive: true, mode: 0o700 });
- fs.writeFileSync(paths.capabilityPath, JSON.stringify({
- version: 1,
- query: 'a'.repeat(64),
- probe: 'b'.repeat(64),
- }), { mode: 0o600 });
- mockExeca.mockReset();
- mockResolveDockerHostGateway.mockReturnValue('172.17.0.1');
- mockExeca.mockResolvedValue({
- exitCode: 0,
- stdout: 'healthy|172.17.0.1:49152\n',
- stderr: '',
- });
- });
-
- afterEach(() => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('rejects a transport other than sbx-http', async () => {
- await expect(resolveSbxIngress({ ...config, boundedAgentIngressTransport: 'unix' } as WrapperConfig))
- .rejects.toThrow(/non-HTTP bounded-agent transport/);
- });
-
- it('fails closed when the Docker host gateway cannot be resolved', async () => {
- mockResolveDockerHostGateway.mockReturnValue(undefined);
- await expect(resolveSbxIngress(config)).rejects.toThrow(/Could not resolve the Docker host-gateway/);
- });
-
- it('returns only the endpoint, two capabilities, and agent-visible artifact paths', async () => {
- const result = await resolveSbxIngress(config);
- const paths = resolveBoundedAgentPaths(workDir);
-
- expect(result).toEqual({
- endpoint: 'http://host.docker.internal:49152/query',
- queryCapability: 'a'.repeat(64),
- probeCapability: 'b'.repeat(64),
- skillPath: paths.skillPath,
- wrapperDir: paths.agentDir,
- });
- const dockerArgs = mockExeca.mock.calls[0][1] as string[];
- expect(dockerArgs.join(' ')).not.toContain('a'.repeat(64));
- expect(dockerArgs.join(' ')).not.toContain('b'.repeat(64));
- });
-
- it.each([
- '0.0.0.0:49152',
- '[::1]:49152',
- '172.17.0.1:0',
- '172.17.0.1:70000',
- '',
- ])('rejects a broad or malformed publication: %s', async (published) => {
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: `healthy|${published}`, stderr: '' });
- await expect(resolveSbxIngress(config)).rejects.toThrow(/narrowly published/);
- });
-
- it('waits for broker health before returning the endpoint', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'starting|', stderr: '' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'healthy|172.17.0.1:49152', stderr: '' });
-
- const result = await resolveSbxIngress(config);
- expect(result.endpoint).toBe('http://host.docker.internal:49152/query');
- expect(mockExeca.mock.calls.length).toBeGreaterThanOrEqual(2);
- });
-
- it('rejects a malformed on-disk capability file', async () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.writeFileSync(paths.capabilityPath, JSON.stringify({ version: 1, query: 'not-hex', probe: 'b'.repeat(64) }));
- await expect(resolveSbxIngress(config)).rejects.toThrow(/malformed/);
- });
-
- it.each([
- { version: 2, query: 'a'.repeat(64), probe: 'b'.repeat(64) },
- { version: 1, query: 1, probe: 'b'.repeat(64) },
- { version: 1, query: 'a'.repeat(64), probe: 1 },
- { version: 1, query: 'not-hex', probe: 'b'.repeat(64) },
- { version: 1, query: 'a'.repeat(64), probe: 'not-hex' },
- { version: 1, query: 'a'.repeat(64), probe: 'a'.repeat(64) },
- ])('rejects malformed capability field combinations: %j', async (capabilities) => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.writeFileSync(paths.capabilityPath, JSON.stringify(capabilities));
- await expect(resolveSbxIngress(config)).rejects.toThrow(/malformed/);
- });
-
- it('removes the private capability file after broker startup and sbx probing', () => {
- const capabilityPath = resolveBoundedAgentPaths(workDir).capabilityPath;
- expect(fs.existsSync(capabilityPath)).toBe(true);
- removeSbxIngressCapabilityFile(config);
- expect(fs.existsSync(capabilityPath)).toBe(false);
- });
-});
diff --git a/src/bounded-agent/ingress.ts b/src/bounded-agent/ingress.ts
deleted file mode 100644
index a420764b0..000000000
--- a/src/bounded-agent/ingress.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import * as fs from 'fs';
-import execa from 'execa';
-import { BOUNDED_AGENT_BROKER_CONTAINER_NAME } from '../constants';
-import { getLocalDockerEnv } from '../host-env';
-import { resolveDockerHostGateway } from '../services/host-gateway';
-import type { WrapperConfig } from '../types';
-import { resolveBoundedAgentPaths } from './paths';
-
-export const BOUNDED_AGENT_TCP_PORT = 18081;
-export const BOUNDED_AGENT_INGRESS_NETWORK = 'awf-bounded-agent-ingress';
-export const SBX_HOST_ALIAS = 'host.docker.internal';
-
-interface SbxIngressCapabilities {
- version: 1;
- query: string;
- probe: string;
-}
-
-export interface ResolvedSbxIngress {
- endpoint: string;
- queryCapability: string;
- probeCapability: string;
- skillPath: string;
- wrapperDir: string;
-}
-
-function readCapabilities(config: WrapperConfig): SbxIngressCapabilities {
- const paths = resolveBoundedAgentPaths(config.workDir);
- const parsed = JSON.parse(fs.readFileSync(paths.capabilityPath, 'utf8')) as Partial;
- const capabilityPattern = /^[0-9a-f]{64}$/;
- if (
- parsed.version !== 1
- || typeof parsed.query !== 'string'
- || typeof parsed.probe !== 'string'
- || !capabilityPattern.test(parsed.query)
- || !capabilityPattern.test(parsed.probe)
- || parsed.query === parsed.probe
- ) {
- throw new Error('Bounded-agent sbx ingress capability file is malformed');
- }
- return parsed as SbxIngressCapabilities;
-}
-
-/**
- * Resolves the healthy host-gateway publication without logging capabilities.
- *
- * Mirrors bounded-query's `resolveSbxIngress` exactly: a primary sbx microVM
- * cannot receive the broker's Unix-socket bind mount, so when the executable
- * passthrough probe fails, the broker is instead published on an ephemeral,
- * host-gateway-only port on a dedicated internal Docker network, and the
- * microVM authenticates with a broker-generated, random per-run capability
- * that is never logged, put in telemetry, or written to any audit/skill file.
- */
-export async function resolveSbxIngress(config: WrapperConfig): Promise {
- if (config.boundedAgentIngressTransport !== 'sbx-http') {
- throw new Error('resolveSbxIngress called for a non-HTTP bounded-agent transport');
- }
- const expectedHostIp = resolveDockerHostGateway();
- if (!expectedHostIp) {
- throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress');
- }
-
- const deadline = Date.now() + 30_000;
- let lastPublished = '';
- let lastHealth = '';
- while (Date.now() < deadline) {
- const result = await execa(
- 'docker',
- [
- 'inspect',
- '--format',
- `{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{with index (index .NetworkSettings.Ports "${BOUNDED_AGENT_TCP_PORT}/tcp") 0}}{{.HostIp}}:{{.HostPort}}{{end}}`,
- BOUNDED_AGENT_BROKER_CONTAINER_NAME,
- ],
- {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 5_000,
- },
- );
- const [health = '', published = ''] = result.stdout.trim().split('|', 2);
- lastHealth = health;
- lastPublished = published;
- const separator = published.lastIndexOf(':');
- const publishedHostIp = separator === -1 ? '' : published.slice(0, separator);
- const publishedPort = separator === -1 ? '' : published.slice(separator + 1);
- const publishedPortNumber = Number(publishedPort);
- const hasValidPort = /^[1-9][0-9]{0,4}$/.test(publishedPort) && publishedPortNumber <= 65535;
- if (result.exitCode === 0 && health === 'healthy' && publishedHostIp === expectedHostIp && hasValidPort) {
- const paths = resolveBoundedAgentPaths(config.workDir);
- const capabilities = readCapabilities(config);
- return {
- endpoint: `http://${SBX_HOST_ALIAS}:${publishedPort}/query`,
- queryCapability: capabilities.query,
- probeCapability: capabilities.probe,
- skillPath: paths.skillPath,
- wrapperDir: paths.agentDir,
- };
- }
- if (result.exitCode === 0 && health === 'healthy') {
- throw new Error(`Bounded-agent sbx ingress is not narrowly published on host-gateway ${expectedHostIp}`);
- }
- await new Promise((resolve) => setTimeout(resolve, 1_000));
- }
-
- throw new Error(
- `Bounded-agent sbx ingress did not become healthy on host-gateway ${expectedHostIp} ` +
- `(health=${lastHealth || 'unknown'}, published=${lastPublished || 'none'})`,
- );
-}
-
-/** Deletes the on-disk secret after the running broker has loaded it. */
-export function removeSbxIngressCapabilityFile(config: WrapperConfig): void {
- fs.rmSync(resolveBoundedAgentPaths(config.workDir).capabilityPath, { force: true });
-}
-
-/** @internal */
-// ts-prune-ignore-next
-export const ingressTestHelpers = { readCapabilities };
diff --git a/src/bounded-agent/manager.test.ts b/src/bounded-agent/manager.test.ts
deleted file mode 100644
index def0513ce..000000000
--- a/src/bounded-agent/manager.test.ts
+++ /dev/null
@@ -1,730 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import execa from 'execa';
-import { logger } from '../logger';
-import type { WrapperConfig } from '../types';
-import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options';
-import { deriveSeedId, resolveBoundedAgentPaths } from './paths';
-import {
- BOUNDED_AGENT_RUN_LABEL,
- boundedAgentManagerTestHelpers,
- isBoundedAgentsEnabled,
- prepareBoundedAgents,
- reportBoundedAgentSbxIngressResult,
- teardownBoundedAgents,
-} from './manager';
-import { releaseSeedPermissions, type GitRunner } from './staging';
-import * as staging from './staging';
-import { resolveBoundedQueryPaths } from '../bounded-query/paths';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-const mockExeca = execa as unknown as jest.Mock;
-
-const boundedAgents: BoundedAgentsConfig = {
- ...BOUNDED_AGENT_DEFAULTS,
- enabled: true,
- model: 'gpt-4o-mini',
- privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
-};
-
-const gitRunner: GitRunner = async (args) => {
- if (args.includes('clone')) {
- const dest = args[args.length - 1];
- fs.mkdirSync(path.join(dest, '.git'), { recursive: true });
- fs.writeFileSync(path.join(dest, '.git', 'config'), '[core]\n');
- fs.writeFileSync(path.join(dest, 'README.md'), 'contents\n');
- return { stdout: '' };
- }
- if (args[0] === 'rev-parse') return { stdout: 'a'.repeat(40) };
- return { stdout: '' };
-};
-
-function buildConfig(workDir: string, overrides: Partial = {}): WrapperConfig {
- return {
- workDir,
- enableApiProxy: true,
- copilotGithubToken: 'gh-real',
- boundedAgents: { ...boundedAgents, ...overrides },
- } as unknown as WrapperConfig;
-}
-
-/** Preflight is proven separately; here it always succeeds unless overridden. */
-const assertRuntimeAvailable = jest.fn(async () => undefined);
-
-describe('isBoundedAgentsEnabled', () => {
- it('is true only for an explicitly enabled config', () => {
- expect(isBoundedAgentsEnabled({} as WrapperConfig)).toBe(false);
- expect(isBoundedAgentsEnabled(buildConfig('/tmp/x', { enabled: false }))).toBe(false);
- expect(isBoundedAgentsEnabled(buildConfig('/tmp/x'))).toBe(true);
- });
-
- describe('deriveSeedId', () => {
- it('derives a stable opaque id from the run and normalized repository', () => {
- const runId = 'a'.repeat(32);
- expect(deriveSeedId(runId, 'Octo/Private')).toBe(deriveSeedId(runId, 'octo/private'));
- expect(deriveSeedId(runId, 'octo/private')).toMatch(/^[0-9a-f]{32}$/);
- });
-
- it('uses an unprivileged root identity fallback when getuid is unavailable', () => {
- const getuid = jest.spyOn(process, 'getuid').mockReturnValue(undefined as unknown as number);
- try {
- expect(path.basename(resolveBoundedAgentPaths('/tmp/example').root)).toMatch(
- /^awf-bounded-agent-private-0-/,
- );
- } finally {
- getuid.mockRestore();
- }
- });
- });
-});
-
-describe('prepareBoundedAgents', () => {
- let workDir: string;
-
- beforeEach(() => {
- mockExeca.mockReset();
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' });
- assertRuntimeAvailable.mockClear();
- assertRuntimeAvailable.mockResolvedValue(undefined);
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-manager-'));
- });
-
- afterEach(() => {
- const paths = resolveBoundedAgentPaths(workDir);
- releaseSeedPermissions(paths.seedsDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('does nothing when bounded agents are disabled', async () => {
- await prepareBoundedAgents(buildConfig(workDir, { enabled: false }));
- expect(fs.existsSync(resolveBoundedAgentPaths(workDir).root)).toBe(false);
- });
-
- it('creates the private layout, seed map, skill, and wrapper artifacts', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
-
- expect(fs.existsSync(paths.seedsDir)).toBe(true);
- expect(fs.existsSync(paths.workDir)).toBe(true);
- expect(fs.existsSync(paths.controlDir)).toBe(true);
- expect(fs.existsSync(paths.auditDir)).toBe(true);
- expect(fs.existsSync(paths.seedMapPath)).toBe(true);
- expect(fs.existsSync(paths.skillPath)).toBe(true);
- expect(fs.existsSync(paths.wrapperPath)).toBe(true);
- });
-
- it('uses a private root disjoint from the bounded-query private root', async () => {
- const agentPaths = resolveBoundedAgentPaths(workDir);
- const queryPaths = resolveBoundedQueryPaths(workDir);
-
- expect(agentPaths.root).not.toBe(queryPaths.root);
- expect(agentPaths.ingressRoot).not.toBe(queryPaths.ingressRoot);
- expect(agentPaths.root.startsWith(queryPaths.root)).toBe(false);
- expect(queryPaths.root.startsWith(agentPaths.root)).toBe(false);
- });
-
- it('runs preflight before staging clones anything', async () => {
- assertRuntimeAvailable.mockRejectedValueOnce(new Error('runsc is not registered'));
- const cloned: string[][] = [];
- const trackingGitRunner: GitRunner = async (args) => {
- cloned.push(args);
- return gitRunner(args, { env: {} });
- };
-
- await expect(
- prepareBoundedAgents(buildConfig(workDir, { runtime: 'gvisor' }), {
- env: { GH_TOKEN: 't' },
- gitRunner: trackingGitRunner,
- assertRuntimeAvailable,
- }),
- ).rejects.toThrow(/runsc is not registered/);
-
- expect(cloned).toEqual([]);
- expect(fs.existsSync(resolveBoundedAgentPaths(workDir).root)).toBe(false);
- });
-
- it('aborts before staging when the configuration is invalid', async () => {
- await expect(
- prepareBoundedAgents({ ...buildConfig(workDir), enableApiProxy: false } as WrapperConfig, {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- }),
- ).rejects.toThrow(/Bounded-agent configuration is invalid/);
- expect(assertRuntimeAvailable).not.toHaveBeenCalled();
- });
-
- it('aborts when no staging credential is available', async () => {
- await expect(
- prepareBoundedAgents(buildConfig(workDir), { env: {}, gitRunner, assertRuntimeAvailable }),
- ).rejects.toThrow(/GH_TOKEN or GITHUB_TOKEN/);
- });
-
- it('scrubs the staging credential and helper before returning', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 'ghs_super_secret' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
-
- expect(fs.existsSync(path.join(paths.root, 'staging-token'))).toBe(false);
- expect(fs.existsSync(path.join(paths.root, 'askpass.sh'))).toBe(false);
- expect(fs.existsSync(path.join(paths.root, 'staging-home'))).toBe(false);
-
- const seedMap = fs.readFileSync(paths.seedMapPath, 'utf8');
- expect(seedMap).not.toContain('ghs_super_secret');
- const skill = fs.readFileSync(paths.skillPath, 'utf8');
- expect(skill).not.toContain('ghs_super_secret');
- });
-
- it('gives staging git no GitHub Actions, OIDC, or inherited credential environment', async () => {
- const observed: NodeJS.ProcessEnv[] = [];
- const capturingGitRunner: GitRunner = async (args, options) => {
- observed.push(options.env);
- return gitRunner(args, options);
- };
- await prepareBoundedAgents(buildConfig(workDir), {
- env: {
- GH_TOKEN: 'ghs_super_secret',
- GITHUB_TOKEN: 'github-fallback',
- ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid',
- ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret',
- GITHUB_ACTIONS: 'true',
- GITHUB_WORKSPACE: '/sensitive/workspace',
- },
- gitRunner: capturingGitRunner,
- assertRuntimeAvailable,
- });
- expect(observed.length).toBeGreaterThan(0);
- for (const env of observed) {
- expect(env).not.toHaveProperty('GH_TOKEN');
- expect(env).not.toHaveProperty('GITHUB_TOKEN');
- expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_URL');
- expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_TOKEN');
- expect(env).not.toHaveProperty('GITHUB_ACTIONS');
- expect(env).not.toHaveProperty('GITHUB_WORKSPACE');
- expect(Object.keys(env).sort()).toEqual([
- 'GIT_ASKPASS',
- 'GIT_CONFIG_COUNT',
- 'GIT_CONFIG_KEY_0',
- 'GIT_CONFIG_NOSYSTEM',
- 'GIT_CONFIG_VALUE_0',
- 'GIT_TERMINAL_PROMPT',
- 'HOME',
- 'PATH',
- 'XDG_CONFIG_HOME',
- 'AWF_BOUNDED_QUERY_STAGING_TOKEN_FILE',
- ].sort());
- }
- });
-
- it('writes a seed map with opaque seed ids and trusted sensitivity only', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
- const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8'));
-
- expect(seedMap.version).toBe(2);
- expect(seedMap.runId).toMatch(/^[0-9a-f]{32}$/);
- expect(seedMap.seeds).toHaveLength(1);
- expect(seedMap.seeds[0].repo).toBe('octo/private');
- expect(seedMap.seeds[0].seedId).toMatch(/^[0-9a-f]{32}$/);
- expect(seedMap.seeds[0].sensitivity).toBe('internal');
- // No host paths leak into broker input.
- expect(JSON.stringify(seedMap)).not.toContain(workDir);
- });
-
- it('protects the private directories and leaves only the ingress agent-readable', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
-
- expect(fs.statSync(paths.root).mode & 0o777).toBe(0o700);
- expect(fs.statSync(paths.auditDir).mode & 0o777).toBe(0o700);
- expect(fs.statSync(paths.seedMapPath).mode & 0o777).toBe(0o600);
- expect(fs.statSync(paths.agentDir).mode & 0o777).toBe(0o755);
- });
-
- it('sanitizes exactly one immutable seed per configured repository', async () => {
- await prepareBoundedAgents(
- buildConfig(workDir, {
- privateRepos: [
- { repo: 'octo/private', sensitivity: 'internal' },
- { repo: 'octo/other', sensitivity: 'confidential' },
- ],
- }),
- { env: { GH_TOKEN: 't' }, gitRunner, assertRuntimeAvailable },
- );
- const paths = resolveBoundedAgentPaths(workDir);
- const seeds = fs.readdirSync(paths.seedsDir);
-
- expect(seeds).toHaveLength(2);
- for (const seed of seeds) {
- const gitConfig = fs.readFileSync(path.join(paths.seedsDir, seed, '.git', 'config'), 'utf8');
- expect(gitConfig).not.toContain('remote');
- expect(gitConfig).not.toContain('url');
- // Seeds are read-only.
- expect(fs.statSync(path.join(paths.seedsDir, seed, 'README.md')).mode & 0o222).toBe(0);
- // The fixed unprivileged enclave uid can traverse and read the direct bind mount.
- expect(fs.statSync(path.join(paths.seedsDir, seed)).mode & 0o005).toBe(0o005);
- expect(fs.statSync(path.join(paths.seedsDir, seed, 'README.md')).mode & 0o004).toBe(0o004);
- }
- });
-
- it('refuses to reuse an existing private root', async () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.root, { recursive: true, mode: 0o700 });
-
- await expect(
- prepareBoundedAgents(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner, assertRuntimeAvailable }),
- ).rejects.toThrow(/EEXIST/);
- });
-
- describe('runtime telemetry lifecycle (never `ready` before sbx ingress is proven)', () => {
- function collectTelemetry(infoSpy: jest.SpyInstance): Array> {
- return infoSpy.mock.calls
- .map((call) => String(call[0]))
- .filter((line) => line.startsWith('Bounded-agent runtime telemetry: '))
- .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length)));
- }
-
- it('reports `ready` immediately after preflight for a compose (docker) primary', async () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const events = collectTelemetry(infoSpy);
- const terminal = events[events.length - 1];
- expect(terminal).toEqual(expect.objectContaining({
- primaryBackend: 'docker',
- capabilityState: 'supported',
- category: 'ready',
- }));
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('never reports `ready` for a primary-sbx run before ingress is proven', async () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- await prepareBoundedAgents(
- { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig,
- {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- probeSbxUnixSocket: async () => true,
- },
- );
- const events = collectTelemetry(infoSpy);
- expect(events.some((event) => event.category === 'ready')).toBe(false);
- const terminal = events[events.length - 1];
- expect(terminal).toEqual(expect.objectContaining({
- primaryBackend: 'sbx',
- capabilityState: 'supported',
- category: 'primary-sbx-ingress-pending',
- }));
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('reports ingress unavailable when primary-sbx transport selection fails', async () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- await expect(prepareBoundedAgents(
- { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig,
- {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- probeSbxUnixSocket: async () => {
- throw new Error('socket probe failed');
- },
- },
- )).rejects.toThrow('socket probe failed');
-
- const events = collectTelemetry(infoSpy);
- expect(events.some((event) => event.category === 'ready')).toBe(false);
- expect(events[events.length - 1]).toEqual(expect.objectContaining({
- primaryBackend: 'sbx',
- lifecycleClass: 'startup',
- capabilityState: 'unavailable',
- category: 'primary-sbx-ingress-unproven',
- }));
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('reportBoundedAgentSbxIngressResult reports `ready` only once ingress proof succeeds', () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig;
- reportBoundedAgentSbxIngressResult(config, 'proven');
- const events = collectTelemetry(infoSpy);
- expect(events).toHaveLength(1);
- expect(events[0]).toEqual(expect.objectContaining({
- primaryBackend: 'sbx',
- lifecycleClass: 'startup',
- capabilityState: 'supported',
- category: 'ready',
- }));
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('reportBoundedAgentSbxIngressResult reports a terminal unavailable event when ingress proof fails', () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig;
- reportBoundedAgentSbxIngressResult(config, 'failed');
- const events = collectTelemetry(infoSpy);
- expect(events).toHaveLength(1);
- expect(events[0]).toEqual(expect.objectContaining({
- primaryBackend: 'sbx',
- lifecycleClass: 'startup',
- capabilityState: 'unavailable',
- category: 'primary-sbx-ingress-unproven',
- }));
- expect(events.some((event) => event.category === 'ready')).toBe(false);
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('reportBoundedAgentSbxIngressResult is a no-op for a non-sbx primary', () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- const config = { ...buildConfig(workDir), containerRuntime: 'gvisor' } as WrapperConfig;
- reportBoundedAgentSbxIngressResult(config, 'proven');
- expect(collectTelemetry(infoSpy)).toHaveLength(0);
- } finally {
- infoSpy.mockRestore();
- }
- });
-
- it('reportBoundedAgentSbxIngressResult is a no-op when bounded agents are disabled', () => {
- const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
- try {
- const config = {
- ...buildConfig(workDir, { enabled: false }),
- containerRuntime: 'sbx',
- } as WrapperConfig;
- reportBoundedAgentSbxIngressResult(config, 'proven');
- expect(collectTelemetry(infoSpy)).toHaveLength(0);
- } finally {
- infoSpy.mockRestore();
- }
- });
- });
-
- it('rejects a symlink work directory before creating private state', async () => {
- const target = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-target-'));
- fs.rmSync(workDir, { recursive: true, force: true });
- fs.symlinkSync(target, workDir);
- try {
- await expect(
- prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- }),
- ).rejects.toThrow(/symlink work directory/);
- } finally {
- fs.unlinkSync(workDir);
- fs.rmSync(target, { recursive: true, force: true });
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-cleanup-'));
- }
- });
-
- it('fails closed if the staging credential disappears after validation', async () => {
- const token = jest.spyOn(staging, 'resolveStagingToken').mockReturnValueOnce(undefined);
- try {
- await expect(
- prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- }),
- ).rejects.toThrow(/credential disappeared/);
- } finally {
- token.mockRestore();
- }
- });
-
- it('creates private sbx-http ingress capabilities only after transport preflight', async () => {
- const probeSbxUnixSocket = jest.fn(async () => false);
- await prepareBoundedAgents(
- { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig,
- {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- assertPrimaryAvailable: jest.fn(async () => undefined),
- probeSbxUnixSocket,
- },
- );
-
- const capabilityPath = resolveBoundedAgentPaths(workDir).capabilityPath;
- const capabilities = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
- expect(capabilities).toEqual({
- version: 1,
- query: expect.stringMatching(/^[0-9a-f]{64}$/),
- probe: expect.stringMatching(/^[0-9a-f]{64}$/),
- });
- expect(capabilities.query).not.toBe(capabilities.probe);
- expect(fs.statSync(capabilityPath).mode & 0o777).toBe(0o600);
- expect(probeSbxUnixSocket).toHaveBeenCalledWith('bounded-agent');
- });
-});
-
-describe('teardownBoundedAgents', () => {
- let workDir: string;
-
- beforeEach(() => {
- mockExeca.mockReset();
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' });
- assertRuntimeAvailable.mockClear();
- assertRuntimeAvailable.mockResolvedValue(undefined);
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-teardown-'));
- });
-
- afterEach(() => {
- const paths = resolveBoundedAgentPaths(workDir);
- releaseSeedPermissions(paths.seedsDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('is a no-op when bounded agents are disabled', async () => {
- await teardownBoundedAgents(buildConfig(workDir, { enabled: false }));
- expect(mockExeca).not.toHaveBeenCalled();
- });
-
- it('deterministically removes orphaned enclave containers by run label', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
- const runId = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')).runId as string;
-
- mockExeca.mockReset();
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'abc123\ndef456\n' });
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '' });
-
- await teardownBoundedAgents(buildConfig(workDir));
-
- expect(mockExeca).toHaveBeenNthCalledWith(
- 1,
- 'docker',
- ['ps', '-aq', '--filter', `label=${BOUNDED_AGENT_RUN_LABEL}=${runId}`],
- expect.anything(),
- );
- expect(mockExeca).toHaveBeenNthCalledWith(
- 2,
- 'docker',
- ['rm', '-f', 'abc123', 'def456'],
- expect.anything(),
- );
- expect(fs.existsSync(paths.root)).toBe(false);
- expect(fs.existsSync(paths.ingressRoot)).toBe(false);
- });
-
- it('uses a run label distinct from bounded queries', () => {
- expect(BOUNDED_AGENT_RUN_LABEL).toBe('awf.bounded-agent.run');
- });
-
- it('removes orphaned enclaves but preserves private state under --keep-containers', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- const paths = resolveBoundedAgentPaths(workDir);
-
- mockExeca.mockReset();
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'abc123\n' });
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '' });
-
- await teardownBoundedAgents({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig);
-
- expect(mockExeca).toHaveBeenCalledTimes(2);
- expect(fs.existsSync(paths.root)).toBe(true);
- });
-
- it('removes a stale ingress root when the private root is already gone', async () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.runDir, { recursive: true });
-
- await teardownBoundedAgents(buildConfig(workDir));
- expect(fs.existsSync(paths.ingressRoot)).toBe(false);
- });
-
- it('preserves a stale ingress root under --keep-containers', async () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.runDir, { recursive: true });
-
- await teardownBoundedAgents({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig);
- expect(fs.existsSync(paths.ingressRoot)).toBe(true);
- });
-
- it('handles missing run ids and seed-permission restoration failures', async () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.root, { recursive: true });
- fs.mkdirSync(paths.ingressRoot, { recursive: true });
- fs.writeFileSync(paths.seedMapPath, '{}');
- const release = jest.spyOn(staging, 'releaseSeedPermissions').mockImplementationOnce(() => {
- throw new Error('permission restore failed');
- });
- try {
- await expect(teardownBoundedAgents(buildConfig(workDir))).resolves.toBeUndefined();
- expect(mockExeca).not.toHaveBeenCalled();
- } finally {
- release.mockRestore();
- }
- });
-
- it('continues cleanup when orphan enumeration fails', async () => {
- await prepareBoundedAgents(buildConfig(workDir), {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- });
- mockExeca.mockRejectedValueOnce(new Error('docker unavailable'));
-
- await expect(teardownBoundedAgents(buildConfig(workDir))).resolves.toBeUndefined();
- });
-});
-
-describe('boundedAgentManagerTestHelpers.readRunId', () => {
- it('returns undefined for a missing or malformed seed map', () => {
- const paths = resolveBoundedAgentPaths('/nonexistent-work-dir-for-tests');
- expect(boundedAgentManagerTestHelpers.readRunId(paths)).toBeUndefined();
- });
-
- describe('boundedAgentManagerTestHelpers.removePrivateState', () => {
- it('repairs rootless permissions and retries both private roots after EACCES', () => {
- const config = buildConfig('/tmp/work');
- const paths = resolveBoundedAgentPaths('/tmp/work');
- const removeTree = jest.fn()
- .mockImplementationOnce(() => {
- const error = new Error('denied') as NodeJS.ErrnoException;
- error.code = 'EACCES';
- throw error;
- })
- .mockImplementation(() => undefined);
- const repairPermissions = jest.fn();
-
- boundedAgentManagerTestHelpers.removePrivateState(config, paths, {
- removeTree,
- repairPermissions,
- });
-
- expect(repairPermissions).toHaveBeenCalledWith(
- [paths.root, paths.ingressRoot],
- config.dockerHostPathPrefix,
- config.imageRegistry,
- config.imageTag,
- config.agentImage,
- );
- expect(removeTree).toHaveBeenCalledTimes(3);
- });
-
- it('does not repair permissions for non-EACCES cleanup failures', () => {
- const config = buildConfig('/tmp/work');
- const paths = resolveBoundedAgentPaths('/tmp/work');
- const removeTree = jest.fn(() => {
- throw new Error('unexpected cleanup failure');
- });
- const repairPermissions = jest.fn();
-
- expect(() => boundedAgentManagerTestHelpers.removePrivateState(config, paths, {
- removeTree,
- repairPermissions,
- })).not.toThrow();
- expect(repairPermissions).not.toHaveBeenCalled();
- });
-
- it('contains a permission-repair retry failure', () => {
- const config = buildConfig('/tmp/work');
- const paths = resolveBoundedAgentPaths('/tmp/work');
- const removeTree = jest.fn(() => {
- const error = new Error('denied') as NodeJS.ErrnoException;
- error.code = 'EACCES';
- throw error;
- });
-
- expect(() => boundedAgentManagerTestHelpers.removePrivateState(config, paths, {
- removeTree,
- repairPermissions: jest.fn(),
- })).not.toThrow();
- expect(removeTree).toHaveBeenCalledTimes(2);
- });
- });
-
- describe('boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers', () => {
- it('returns when Docker enumeration fails or finds no containers', async () => {
- mockExeca.mockReset();
- mockExeca
- .mockResolvedValueOnce({ exitCode: 1, stdout: '' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '\n' });
-
- await boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers('a'.repeat(32));
- await boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers('b'.repeat(32));
- expect(mockExeca).toHaveBeenCalledTimes(2);
- });
- });
-
- describe('boundedAgentManagerTestHelpers.prepareDirectories', () => {
- it('hands private proxy logs to the safe host identity under sudo', () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-owner-'));
- const paths = resolveBoundedAgentPaths(workDir);
- const getuid = jest.spyOn(process, 'getuid').mockReturnValue(0);
- const getgid = jest.spyOn(process, 'getgid').mockReturnValue(0);
- const chown = jest.fn();
- const previousUid = process.env.SUDO_UID;
- const previousGid = process.env.SUDO_GID;
- process.env.SUDO_UID = '1234';
- process.env.SUDO_GID = '5678';
-
- try {
- boundedAgentManagerTestHelpers.prepareDirectories(paths, chown);
- expect(chown).toHaveBeenCalledWith(paths.runDir, 1234, 5678);
- expect(chown).toHaveBeenCalledWith(paths.apiProxyLogsDir, 1234, 5678);
- } finally {
- getuid.mockRestore();
- getgid.mockRestore();
- if (previousUid === undefined) delete process.env.SUDO_UID;
- else process.env.SUDO_UID = previousUid;
- if (previousGid === undefined) delete process.env.SUDO_GID;
- else process.env.SUDO_GID = previousGid;
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
- });
-});
diff --git a/src/bounded-agent/manager.ts b/src/bounded-agent/manager.ts
deleted file mode 100644
index 2f2b149b9..000000000
--- a/src/bounded-agent/manager.ts
+++ /dev/null
@@ -1,458 +0,0 @@
-import * as fs from 'fs';
-import * as crypto from 'crypto';
-import execa from 'execa';
-import { logger } from '../logger';
-import { getLocalDockerEnv } from '../host-env';
-import { getSafeHostUid, getSafeHostGid } from '../host-identity';
-import type { WrapperConfig } from '../types';
-import {
- generateBoundedAgentRunId,
- resolveBoundedAgentPaths,
- type BoundedAgentPaths,
-} from './paths';
-import {
- assertEnclaveRuntimeAvailable,
- assertPrimaryRuntimeAvailable,
- validateBoundedAgentConfig,
-} from './preflight';
-import { writeBoundedAgentSkill } from './skill';
-import { writeBoundedAgentWrapper } from './wrapper-artifact';
-import { releaseSeedPermissions, resolveStagingToken, stageBoundedAgentSeeds, type GitRunner } from './staging';
-import {
- PRIVATE_REPOSITORY_SEED_MAP_VERSION,
- serializePrivateRepositorySeedMap,
- type PrivateRepositorySeedMap,
-} from '../bounded-execution/repository-staging';
-import { assertBoundedAgentPrivateRootIsolated } from './mount-policy';
-import { fixArtifactPermissionsForRootless } from '../artifact-permissions';
-import { runtimeUsesComposeAgent } from '../container-runtime';
-import { probeSbxUnixSocketMount } from '../sbx-manager';
-import {
- resolveBoundedAgentPrimaryBackend,
- serializeBoundedAgentRuntimeTelemetry,
-} from './runtime-matrix';
-import {
- type SbxIngressCapabilities,
- writeSbxIngressCapabilitiesFile,
-} from '../bounded-execution/sbx-ingress-capabilities';
-
-/**
- * Bounded-agent lifecycle orchestration.
- *
- * `prepareBoundedAgents` runs entirely on the trusted AWF host **before** any
- * configuration is generated or any container is started, so that:
- *
- * - the primary agent never starts when preflight or staging fails;
- * - the staging credential is consumed and discarded before the broker, the
- * agent, and any enclave exist;
- * - compose generation can rely on the on-disk layout already being present.
- *
- * `teardownBoundedAgents` deterministically removes orphaned enclave
- * containers (matched by this run's Docker label) and the separate
- * broker-private host root.
- */
-
-/** Docker label applied to every enclave container, used for orphan cleanup. */
-export const BOUNDED_AGENT_RUN_LABEL = 'awf.bounded-agent.run';
-
-/** Returns true when this run must stage seeds and start the bounded-agent broker. */
-export function isBoundedAgentsEnabled(config: WrapperConfig): boolean {
- return config.boundedAgents?.enabled === true;
-}
-
-/** Creates a directory with an exact mode, independent of the process umask. */
-function ensureModeDirectory(target: string, mode: number): void {
- fs.mkdirSync(target, { recursive: true, mode });
- fs.chmodSync(target, mode);
-}
-
-/**
- * Creates the bounded-agent directory layout.
- *
- * The private root is created without `recursive` so a pre-existing path,
- * including a symlink planted between preflight and creation, fails closed.
- */
-function prepareDirectories(
- paths: BoundedAgentPaths,
- chown: typeof fs.chownSync = fs.chownSync,
-): void {
- fs.mkdirSync(paths.root, { mode: 0o700 });
- fs.mkdirSync(paths.ingressRoot, { mode: 0o700 });
- ensureModeDirectory(paths.seedsDir, 0o700);
- ensureModeDirectory(paths.workDir, 0o700);
- ensureModeDirectory(paths.controlDir, 0o700);
- ensureModeDirectory(paths.auditDir, 0o700);
- ensureModeDirectory(paths.apiProxyLogsDir, 0o700);
- ensureModeDirectory(paths.runDir, 0o770);
- ensureModeDirectory(paths.agentDir, 0o755);
-
- // Under sudo these directories start root-owned. Hand the socket and private
- // proxy log directories to the non-root identity used by their containers.
- if (process.getuid?.() === 0) {
- const hostUid = parseInt(getSafeHostUid(), 10);
- const hostGid = parseInt(getSafeHostGid(), 10);
- chown(paths.runDir, hostUid, hostGid);
- chown(paths.apiProxyLogsDir, hostUid, hostGid);
- }
-}
-
-interface RemovePrivateStateDeps {
- removeTree?: (target: string) => void;
- repairPermissions?: typeof fixArtifactPermissionsForRootless;
-}
-
-function removePrivateState(
- config: WrapperConfig,
- paths: BoundedAgentPaths,
- deps: RemovePrivateStateDeps = {},
-): void {
- const removeTree = deps.removeTree ?? ((target: string) => {
- fs.rmSync(target, { recursive: true, force: true });
- });
- const repairPermissions = deps.repairPermissions ?? fixArtifactPermissionsForRootless;
-
- try {
- removeTree(paths.root);
- removeTree(paths.ingressRoot);
- } catch (error: unknown) {
- if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') {
- logger.debug('Bounded agents: repairing rootless private-state permissions before cleanup');
- repairPermissions(
- [paths.root, paths.ingressRoot],
- config.dockerHostPathPrefix,
- config.imageRegistry,
- config.imageTag,
- config.agentImage,
- );
- try {
- removeTree(paths.root);
- removeTree(paths.ingressRoot);
- } catch (retryError) {
- logger.warn('Bounded agents: failed to remove private state after permission repair', retryError);
- }
- return;
- }
- logger.warn('Bounded agents: failed to remove private state during cleanup', error);
- }
-}
-
-/** Writes the broker's repo → opaque seed map. */
-function writeSeedMap(paths: BoundedAgentPaths, seedMap: PrivateRepositorySeedMap): void {
- const content = serializePrivateRepositorySeedMap(seedMap);
- // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file
- // is already at this path (insecure-temp-file guard).
- const fd = fs.openSync(
- paths.seedMapPath,
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
- 0o600,
- );
- try {
- fs.writeSync(fd, content);
- fs.fchmodSync(fd, 0o600);
- } finally {
- fs.closeSync(fd);
- }
-}
-
-export interface PrepareBoundedAgentsDeps {
- /** Override the git runner (tests). */
- gitRunner?: GitRunner;
- /** Override the host environment the staging credential is read from. */
- env?: NodeJS.ProcessEnv;
- /** Override the sbx Unix-socket passthrough probe (tests). */
- probeSbxUnixSocket?: typeof probeSbxUnixSocketMount;
- /** Override enclave-runtime capability preflight (tests). */
- assertRuntimeAvailable?: typeof assertEnclaveRuntimeAvailable;
- /** Override primary-runtime capability preflight (tests). */
- assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable;
-}
-
-function writeSbxIngressCapabilities(paths: BoundedAgentPaths): void {
- const capabilities: SbxIngressCapabilities = {
- version: 1,
- query: crypto.randomBytes(32).toString('hex'),
- probe: crypto.randomBytes(32).toString('hex'),
- };
- writeSbxIngressCapabilitiesFile(paths.capabilityPath, capabilities);
-}
-
-/**
- * Validates configuration, proves both the primary-agent runtime and the
- * enclave runtime are independently available, stages one immutable seed per
- * configured repository, and writes the broker/agent artifacts.
- *
- * Ordering is a security property: preflight runs *before* staging, so a run
- * that could never launch an enclave never clones a private repository, and
- * the staging credential is discarded before any container exists. Each
- * preflight axis is proven independently and neither ever falls back to a
- * weaker backend on failure; every terminal state is reported as narrow,
- * content-free runtime telemetry (backend names and capability state only —
- * never secrets, paths, prompts, repo names, or model payloads).
- *
- * Throws on any failure — the caller must abort the run.
- */
-export async function prepareBoundedAgents(
- config: WrapperConfig,
- deps: PrepareBoundedAgentsDeps = {},
-): Promise {
- const boundedAgents = config.boundedAgents;
- if (!boundedAgents?.enabled) return;
-
- const env = deps.env ?? process.env;
- const errors = validateBoundedAgentConfig(config, env);
- if (errors.length > 0) {
- throw new Error(`Bounded-agent configuration is invalid:\n - ${errors.join('\n - ')}`);
- }
-
- const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime);
- const telemetryBase = {
- primaryBackend,
- boundedAgentBackend: boundedAgents.runtime,
- lifecycleClass: 'preflight' as const,
- };
- const assertRuntimeAvailable = deps.assertRuntimeAvailable ?? assertEnclaveRuntimeAvailable;
- const assertPrimaryAvailable = deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable;
- try {
- await assertPrimaryAvailable(config.containerRuntime);
- } catch (error) {
- logger.info(
- `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: 'unavailable',
- category: 'primary-runtime-unavailable',
- })}`,
- );
- throw error;
- }
- try {
- await assertRuntimeAvailable(boundedAgents);
- } catch (error) {
- logger.info(
- `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: boundedAgents.runtime === 'sbx' ? 'blocked' : 'unavailable',
- category: boundedAgents.runtime === 'sbx' ? 'enclave-security-block' : 'enclave-runtime-unavailable',
- })}`,
- );
- throw error;
- }
- // A primary-sbx run is never reported `ready` here: preflight only proves the
- // sbx CLI and enclave capability exist, not that the selected ingress
- // transport (unix-in-sbx or sbx-http) is actually reachable from inside the
- // sandbox. That executable proof happens later in `main-action`, after the
- // sandbox is created, via `assertSbxBoundedAgentIngress`. Reporting `ready`
- // here would be a false promotion — see
- // `reportBoundedAgentSbxIngressResult` for the deferred terminal event.
- // Compose primaries (docker/gvisor) have no equivalent later proof step —
- // Compose either mounts the broker socket successfully or fails outright —
- // so `ready` is accurate immediately after preflight for those backends.
- logger.info(
- `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: 'supported',
- category: primaryBackend === 'sbx' ? 'primary-sbx-ingress-pending' : 'ready',
- })}`,
- );
-
- if (runtimeUsesComposeAgent(config.containerRuntime)) {
- config.boundedAgentIngressTransport = 'unix';
- } else {
- const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount;
- try {
- config.boundedAgentIngressTransport = (await probe('bounded-agent')) ? 'unix' : 'sbx-http';
- } catch (error) {
- reportBoundedAgentSbxIngressResult(config, 'failed');
- throw error;
- }
- }
-
- const paths = resolveBoundedAgentPaths(config.workDir);
- assertBoundedAgentPrivateRootIsolated(config, paths, env);
-
- const token = resolveStagingToken(env);
- if (!token) {
- // Already covered by validateBoundedAgentConfig; re-checked so the token is
- // never `undefined!`-asserted into the staging call.
- throw new Error('Bounded-agent staging credential disappeared between validation and staging');
- }
-
- // Guard against symlink injection before writing any credential-bearing state.
- try {
- const lstat = fs.lstatSync(config.workDir);
- if (lstat.isSymbolicLink()) {
- throw new Error(`Refusing to stage into a symlink work directory: ${config.workDir}`);
- }
- } catch (error: unknown) {
- if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
- throw error;
- }
- }
-
- prepareDirectories(paths);
- if (config.boundedAgentIngressTransport === 'sbx-http') {
- writeSbxIngressCapabilities(paths);
- }
-
- const runId = generateBoundedAgentRunId();
- const staging = await stageBoundedAgentSeeds({
- repos: boundedAgents.privateRepos,
- paths,
- runId,
- token,
- gitRunner: deps.gitRunner,
- });
-
- writeSeedMap(paths, {
- version: PRIVATE_REPOSITORY_SEED_MAP_VERSION,
- runId: staging.runId,
- seeds: staging.seeds.map((seed) => ({
- repo: seed.repoKey,
- seedId: seed.seedId,
- sensitivity: seed.sensitivity,
- })),
- });
-
- writeBoundedAgentSkill(paths, {
- repos: boundedAgents.privateRepos,
- timeoutSeconds: boundedAgents.timeout,
- maxInvocations: boundedAgents.maxInvocations,
- maxTaskBytes: boundedAgents.maxTaskBytes,
- engine: boundedAgents.engine,
- });
- writeBoundedAgentWrapper(paths);
-
- logger.info(
- `Bounded agents: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`,
- );
-}
-
-/**
- * Emits the terminal bounded-agent runtime telemetry for a primary-sbx run,
- * once `assertSbxBoundedAgentIngress` has actually been attempted in
- * `main-action` after the sandbox exists.
- *
- * `prepareBoundedAgents` deliberately never reports `ready` for a primary-sbx
- * run by itself (see the `primary-sbx-ingress-pending` telemetry emitted
- * there): preflight only proves the sbx CLI and enclave capability are
- * present, not that the selected ingress transport is reachable from inside
- * the sandbox. This function is the only place that reports the outcome of
- * that later, executable proof — `ready`/`supported` only on success, a
- * distinct terminal `unavailable` category on failure. It is a no-op when
- * bounded agents are disabled or the primary backend is not sbx, so callers
- * may invoke it unconditionally around the ingress-proof call site.
- */
-export function reportBoundedAgentSbxIngressResult(
- config: WrapperConfig,
- outcome: 'proven' | 'failed',
-): void {
- const boundedAgents = config.boundedAgents;
- if (!boundedAgents?.enabled) return;
- const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime);
- if (primaryBackend !== 'sbx') return;
-
- const telemetryBase = {
- primaryBackend,
- boundedAgentBackend: boundedAgents.runtime,
- lifecycleClass: 'startup' as const,
- };
- logger.info(
- `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry(
- outcome === 'proven'
- ? { ...telemetryBase, capabilityState: 'supported', category: 'ready' }
- : {
- ...telemetryBase,
- capabilityState: 'unavailable',
- category: 'primary-sbx-ingress-unproven',
- },
- )}`,
- );
-}
-
-/** Reads back the run id recorded during staging, if it is still available. */
-function readRunId(paths: BoundedAgentPaths): string | undefined {
- try {
- const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as PrivateRepositorySeedMap;
- return typeof parsed.runId === 'string' && parsed.runId.length > 0 ? parsed.runId : undefined;
- } catch {
- return undefined;
- }
-}
-
-/** Force-removes any enclave container still labelled with this run. */
-async function removeOrphanEnclaveContainers(runId: string): Promise {
- const filter = `label=${BOUNDED_AGENT_RUN_LABEL}=${runId}`;
- const listed = await execa('docker', ['ps', '-aq', '--filter', filter], {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 30_000,
- });
- if (listed.exitCode !== 0) return;
-
- const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean);
- if (ids.length === 0) return;
-
- logger.debug(`Bounded agents: removing ${ids.length} orphaned enclave container(s)`);
- await execa('docker', ['rm', '-f', ...ids], {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 60_000,
- });
-}
-
-/**
- * Tears down bounded-agent state.
- *
- * Orphaned enclave containers are always removed — including under
- * `--keep-containers` — because they are ephemeral, hold a private copy of
- * repository contents, and are never useful for debugging.
- *
- * Restoring seed permissions is skipped under `--keep-containers`. When it does
- * run, it must run before AWF's generic work-directory cleanup: seeds are
- * deliberately read-only, and `rm -rf` cannot unlink entries inside a directory
- * whose write bit was stripped.
- */
-export async function teardownBoundedAgents(config: WrapperConfig): Promise {
- if (!isBoundedAgentsEnabled(config)) return;
-
- const paths = resolveBoundedAgentPaths(config.workDir);
- if (!fs.existsSync(paths.root)) {
- if (!config.keepContainers) {
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- }
- return;
- }
-
- const runId = readRunId(paths);
- if (runId) {
- try {
- await removeOrphanEnclaveContainers(runId);
- } catch (error) {
- logger.warn('Bounded agents: failed to remove orphaned enclave containers', error);
- }
- }
-
- if (config.keepContainers) {
- logger.info(`Bounded-agent private state preserved at: ${paths.root}`);
- logger.info(`Bounded-agent ingress preserved at: ${paths.ingressRoot}`);
- return;
- }
-
- try {
- releaseSeedPermissions(paths.seedsDir);
- } catch (error) {
- logger.warn('Bounded agents: failed to restore seed permissions before cleanup', error);
- }
-
- removePrivateState(config, paths);
-}
-
-/** @internal Exported for focused unit tests. */
-// ts-prune-ignore-next
-export const boundedAgentManagerTestHelpers = {
- prepareDirectories,
- writeSeedMap,
- readRunId,
- removeOrphanEnclaveContainers,
- removePrivateState,
- writeSbxIngressCapabilities,
-};
diff --git a/src/bounded-agent/mount-policy.ts b/src/bounded-agent/mount-policy.ts
deleted file mode 100644
index 937e66cd7..000000000
--- a/src/bounded-agent/mount-policy.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { WrapperConfig } from '../types';
-import { assertPrivateRootIsolated } from '../bounded-query/mount-policy';
-import type { BoundedAgentPaths } from './paths';
-
-/**
- * Fails closed when the bounded-agent broker-private root aliases, contains,
- * or is contained by any path visible to a primary agent in any supported
- * sandbox backend.
- *
- * The agent-visible path union is backend-independent and identical for both
- * bounded subsystems, so this delegates to the audited shared implementation
- * rather than restating it. Only the roots being checked differ.
- */
-export function assertBoundedAgentPrivateRootIsolated(
- config: WrapperConfig,
- paths: Pick,
- env: NodeJS.ProcessEnv = process.env,
- cwd = process.cwd(),
-): void {
- assertPrivateRootIsolated(config, paths, env, cwd, 'bounded-agent');
-}
diff --git a/src/bounded-agent/network.ts b/src/bounded-agent/network.ts
deleted file mode 100644
index f15d95ebf..000000000
--- a/src/bounded-agent/network.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Dedicated bounded-agent enclave network.
- *
- * The enclave is deliberately *not* a member of `awf-net` or `awf-ext`: it has
- * no Squid route, no general proxy, no DNS route to the internet, and no path
- * to the primary agent, the broker, the safe-outputs collector, the MCP
- * gateway, or the CLI proxy. Its only reachable peer is the AWF API proxy,
- * which is dedicated to bounded-agent traffic, joins a separate egress bridge,
- * and remains the only component holding real provider credentials. Its logs,
- * metrics, and quota state are never shared with the primary agent.
- *
- * The network is created by Compose with an explicit `name:` so the broker —
- * which launches enclaves with a fixed `docker run --network ` argument
- * vector — never has to derive a Compose project prefix at runtime.
- */
-
-/** Compose key and concrete Docker network name for the enclave network. */
-export const BOUNDED_AGENT_NETWORK = 'awf-bounded-agent';
-
-/** Egress bridge joined only by the dedicated bounded-agent API proxy. */
-export const BOUNDED_AGENT_EGRESS_NETWORK = 'awf-bounded-agent-egress';
-
-/**
- * Fixed subnet for the enclave network.
- *
- * Deliberately disjoint from the `awf-net` subnet (172.30.0.0/24) and from the
- * bounded-query sbx ingress bridge so the two topologies can never alias.
- */
-export const BOUNDED_AGENT_SUBNET = '172.31.0.0/24';
-
-/** Fixed API proxy address on the enclave network. */
-export const BOUNDED_AGENT_API_PROXY_IP = '172.31.0.30';
-
-/**
- * Fixed DNS alias for the API proxy on the enclave network.
- *
- * The enclave addresses the proxy by IP (Docker's embedded resolver is not
- * guaranteed to be reachable from every runtime), but the alias is published
- * so operators can reason about the topology and so a future runtime that does
- * have DNS keeps working without a protocol change.
- */
-export const BOUNDED_AGENT_API_PROXY_ALIAS = 'awf-bounded-agent-api-proxy';
diff --git a/src/bounded-agent/paths.ts b/src/bounded-agent/paths.ts
deleted file mode 100644
index 34fc0afaa..000000000
--- a/src/bounded-agent/paths.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import * as crypto from 'crypto';
-import * as path from 'path';
-
-/**
- * Filesystem layout and fixed container paths for the bounded-agent subsystem.
- *
- * The layout mirrors bounded queries deliberately: broker-private state and
- * the only agent-visible artifacts live in disjoint, run-specific host roots
- * outside `/tmp`, and only the ingress root is mounted into the primary agent.
- * The roots are *separate* from the bounded-query roots so the two subsystems
- * never share seeds, workspaces, audit state, or a ledger.
- *
- * Layout (host side):
- *
- * ```text
- * /var/tmp/awf-bounded-agent-private--/
- * seeds// immutable, read-only repository seed (one per repo)
- * work/ broker-owned per-invocation state (task, schema, result)
- * control/ broker readiness and other private control state
- * audit/ protected broker diagnostics (never agent-visible)
- * seed-map.json normalized repo -> opaque seed id map (broker input)
- *
- * /var/tmp/awf-bounded-agent-ingress--/
- * run/ broker Unix socket, shared read-write with the agent
- * skill/ generated SKILL.md and wrapper, shared read-only
- * ```
- */
-export interface BoundedAgentPaths {
- /** Dedicated broker-private host root. Never mounted into the primary agent. */
- root: string;
- /** Immutable per-repository seeds. Mounted read-only into the broker. */
- seedsDir: string;
- /** Broker-owned scratch space for per-invocation enclave state. */
- workDir: string;
- /** Broker-private readiness and control state. */
- controlDir: string;
- /** Parent of the only bounded-agent artifacts visible to the primary agent. */
- ingressRoot: string;
- /** Directory holding the broker's Unix socket, shared with the agent. */
- runDir: string;
- /** Directory holding agent-visible artifacts (the generated SKILL.md). */
- agentDir: string;
- /** Protected broker diagnostics. Never mounted into the agent or an enclave. */
- auditDir: string;
- /** Dedicated API-proxy telemetry. Never mounted into the primary agent. */
- apiProxyLogsDir: string;
- /** Repo → seed map consumed by the broker. */
- seedMapPath: string;
- /** Host path of the broker's Unix socket. */
- socketPath: string;
- /** Host path of the generated skill document. */
- skillPath: string;
- /** Host path of the agent-facing bounded-agent executable. */
- wrapperPath: string;
- /** Broker-private path containing ephemeral sbx ingress capabilities. */
- capabilityPath: string;
-}
-
-/** Broker-private state is deliberately outside the agent's broad `/tmp` mount. */
-export const BOUNDED_AGENT_PRIVATE_BASE_DIR = '/var/tmp';
-
-/** Name of the broker's Unix domain socket inside {@link BoundedAgentPaths.runDir}. */
-export const BOUNDED_AGENT_SOCKET_FILENAME = 'broker.sock';
-
-/** Name of the generated skill document inside {@link BoundedAgentPaths.agentDir}. */
-export const BOUNDED_AGENT_SKILL_FILENAME = 'SKILL.md';
-
-/** Name of the generated agent-facing executable. */
-export const BOUNDED_AGENT_WRAPPER_FILENAME = 'bounded-agent';
-
-/** Name of the broker-private sbx ingress capability file. */
-export const BOUNDED_AGENT_CAPABILITY_FILENAME = 'sbx-ingress.json';
-
-// ── Fixed container paths ────────────────────────────────────────────────────
-//
-// These are part of the agent-visible contract (the wrapper and the generated
-// skill reference them verbatim) and of the broker contract, so they are
-// centralized here rather than duplicated across shell/JS/TS.
-
-/** Directory the broker socket is mounted at inside the agent container. */
-export const AGENT_SOCKET_DIR = '/run/awf-bounded-agent';
-
-/** Full socket path as seen from inside the agent container. */
-export const AGENT_SOCKET_PATH = `${AGENT_SOCKET_DIR}/${BOUNDED_AGENT_SOCKET_FILENAME}`;
-
-/** Directory the generated skill is mounted at inside the agent container. */
-export const AGENT_SKILL_DIR = '/run/awf-bounded-agent-skill';
-
-/** Full skill path as seen from inside the agent container. */
-export const AGENT_SKILL_PATH = `${AGENT_SKILL_DIR}/${BOUNDED_AGENT_SKILL_FILENAME}`;
-
-/** Seeds mount point inside the broker container (read-only). */
-export const BROKER_SEEDS_DIR = '/srv/awf/seeds';
-
-/** Per-invocation scratch mount point inside the broker container. */
-export const BROKER_WORK_DIR = '/srv/awf/work';
-
-/** Seed-map mount point inside the broker container (read-only). */
-export const BROKER_SEED_MAP_PATH = '/srv/awf/seed-map.json';
-
-/** Socket directory inside the broker container. */
-export const BROKER_SOCKET_DIR = '/run/awf-bounded-agent';
-
-/** Protected diagnostics directory inside the broker container. */
-export const BROKER_AUDIT_DIR = '/var/log/awf-bounded-agent';
-
-/** Broker-private control directory inside the broker container. */
-export const BROKER_CONTROL_DIR = '/run/awf-bounded-agent-control';
-
-/** Docker socket mount point inside the broker container. */
-export const BROKER_DOCKER_SOCKET_PATH = '/var/run/docker.sock';
-
-/** Writable working directory mounted into each enclave container. */
-export const ENCLAVE_MOUNT_DIR = '/agent';
-
-/** Fixed read-only path the immutable repository seed is mounted at. */
-export const ENCLAVE_SEED_PATH = '/awf/seed';
-
-/** Fixed read-only path the caller's bounded task text is mounted at. */
-export const ENCLAVE_TASK_PATH = '/awf/task.txt';
-
-/** Fixed read-only path the caller's finite response schema is mounted at. */
-export const ENCLAVE_SCHEMA_PATH = '/awf/schema.json';
-
-/** Derives the private root identity without revealing the work-directory path. */
-function deriveRootIdentity(awfWorkDir: string): string {
- const uid = process.getuid?.() ?? 0;
- const digest = crypto
- .createHash('sha256')
- .update(path.resolve(awfWorkDir), 'utf8')
- .digest('hex')
- .slice(0, 20);
- return `${uid}-${digest}`;
-}
-
-/** Derives every bounded-agent path from the AWF work directory. */
-export function resolveBoundedAgentPaths(
- awfWorkDir: string,
- privateBaseDir = BOUNDED_AGENT_PRIVATE_BASE_DIR,
-): BoundedAgentPaths {
- const rootIdentity = deriveRootIdentity(awfWorkDir);
- const root = path.join(privateBaseDir, `awf-bounded-agent-private-${rootIdentity}`);
- const ingressRoot = path.join(privateBaseDir, `awf-bounded-agent-ingress-${rootIdentity}`);
- const runDir = path.join(ingressRoot, 'run');
- const agentDir = path.join(ingressRoot, 'skill');
- return {
- root,
- seedsDir: path.join(root, 'seeds'),
- workDir: path.join(root, 'work'),
- controlDir: path.join(root, 'control'),
- ingressRoot,
- runDir,
- agentDir,
- auditDir: path.join(root, 'audit'),
- apiProxyLogsDir: path.join(root, 'api-proxy-logs'),
- seedMapPath: path.join(root, 'seed-map.json'),
- socketPath: path.join(runDir, BOUNDED_AGENT_SOCKET_FILENAME),
- skillPath: path.join(agentDir, BOUNDED_AGENT_SKILL_FILENAME),
- wrapperPath: path.join(agentDir, BOUNDED_AGENT_WRAPPER_FILENAME),
- capabilityPath: path.join(root, 'control', BOUNDED_AGENT_CAPABILITY_FILENAME),
- };
-}
-
-/**
- * Normalizes an `owner/repo` slug for allowlist lookups.
- *
- * GitHub treats owner and repository names case-insensitively, so the lookup
- * key is lowercased. The *original* spelling is retained separately by the
- * staging phase for clone-URL construction.
- */
-export function normalizeRepoKey(repo: string): string {
- return repo.trim().toLowerCase();
-}
-
-/** Generates the random, run-unique identifier used to derive opaque seed ids. */
-export function generateBoundedAgentRunId(): string {
- return crypto.randomBytes(16).toString('hex');
-}
-
-/**
- * Derives the opaque on-disk seed directory name for a repository.
- *
- * The identifier is a keyed digest of the run id and the normalized repo, so
- * it is stable within a run, unpredictable across runs, and reveals nothing
- * about the repository name to anything that can observe only the path.
- */
-export function deriveSeedId(runId: string, repo: string): string {
- return crypto
- .createHmac('sha256', Buffer.from(runId, 'utf8'))
- .update(normalizeRepoKey(repo), 'utf8')
- .digest('hex')
- .slice(0, 32);
-}
diff --git a/src/bounded-agent/preflight.test.ts b/src/bounded-agent/preflight.test.ts
deleted file mode 100644
index 1a82307cb..000000000
--- a/src/bounded-agent/preflight.test.ts
+++ /dev/null
@@ -1,403 +0,0 @@
-import {
- assertEnclaveRuntimeAvailable,
- assertPrimaryRuntimeAvailable,
- resolveApiProxyRoute,
- validateBoundedAgentConfig,
-} from './preflight';
-import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options';
-import {
- BOUNDED_AGENT_PROFILES,
- BOUNDED_AGENT_SENSITIVITIES,
- BOUNDED_AGENT_SENSITIVITY_RUN_BITS,
- type WrapperConfig,
-} from '../types';
-import * as boundedQueryPreflight from '../bounded-query/preflight';
-import execa from 'execa';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-const mockExeca = execa as unknown as jest.Mock;
-
-/**
- * Fail-closed preflight coverage.
- *
- * Everything here must abort the run *before* staging clones a private
- * repository and before any container exists, and nothing may ever downgrade
- * to a weaker runtime.
- */
-
-const boundedAgents = (overrides: Partial = {}): BoundedAgentsConfig => ({
- ...BOUNDED_AGENT_DEFAULTS,
- enabled: true,
- model: 'gpt-4o-mini',
- privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }],
- ...overrides,
-});
-
-const config = (overrides: Partial = {}): WrapperConfig => ({
- enableApiProxy: true,
- copilotGithubToken: 'github-token',
- boundedAgents: boundedAgents(),
- ...overrides,
-} as WrapperConfig);
-
-const env = { GH_TOKEN: 'ghs_token' } as NodeJS.ProcessEnv;
-
-describe('validateBoundedAgentConfig', () => {
- it('exports the bounded-agent defaults and finite sensitivity policy', () => {
- expect(BOUNDED_AGENT_PROFILES).toEqual(['openai', 'anthropic']);
- expect(BOUNDED_AGENT_SENSITIVITIES).toEqual(['public', 'internal', 'confidential', 'sealed']);
- expect(BOUNDED_AGENT_SENSITIVITY_RUN_BITS.confidential).toBeGreaterThan(0);
- });
-
- it('accepts a complete, minimal configuration', () => {
- expect(validateBoundedAgentConfig(config(), env)).toEqual([]);
- });
-
- it('uses the host environment by default', () => {
- const previous = process.env.GH_TOKEN;
- process.env.GH_TOKEN = 'ghs_test';
- try {
- expect(validateBoundedAgentConfig(config())).toEqual([]);
- } finally {
- if (previous === undefined) delete process.env.GH_TOKEN;
- else process.env.GH_TOKEN = previous;
- }
- });
-
- it('is a no-op when bounded agents are not enabled', () => {
- expect(validateBoundedAgentConfig(config({ boundedAgents: undefined }), {})).toEqual([]);
- expect(
- validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ enabled: false }) }), {}),
- ).toEqual([]);
- });
-
- it('requires the API proxy', () => {
- const errors = validateBoundedAgentConfig(config({ enableApiProxy: false }), env);
- expect(errors.join('\n')).toMatch(/require the AWF API proxy/);
- });
-
- it('requires a configured API target for the selected engine', () => {
- const missing = validateBoundedAgentConfig(config({ copilotGithubToken: undefined }), env);
- expect(missing.join('\n')).toMatch(/supported configured API target for engine "copilot"/);
- });
-
- it('fails closed for schema-recognized engines without native enclave images', () => {
- for (const engine of ['claude', 'codex', 'gemini'] as const) {
- expect(validateBoundedAgentConfig(
- config({ boundedAgents: boundedAgents({ engine }) }),
- env,
- ).join('\n')).toMatch(/is not implemented/);
- }
- });
-
- it('requires a model', () => {
- const errors = validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ model: '' }) }), env);
- expect(errors.join('\n')).toMatch(/boundedAgents\.model is required/);
- });
-
- it('requires a staging credential', () => {
- const errors = validateBoundedAgentConfig(config(), {});
- expect(errors.join('\n')).toMatch(/GH_TOKEN or GITHUB_TOKEN/);
- });
-
- it('requires a non-empty, unique, bare owner/repo allowlist', () => {
- expect(
- validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ privateRepos: [] }) }), env)
- .join('\n'),
- ).toMatch(/privateRepos is empty/);
-
- expect(
- validateBoundedAgentConfig(
- config({
- boundedAgents: boundedAgents({
- privateRepos: [
- { repo: 'octo/alpha', sensitivity: 'internal' },
- { repo: 'Octo/Alpha', sensitivity: 'public' },
- ],
- }),
- }),
- env,
- ).join('\n'),
- ).toMatch(/duplicate entry/);
-
- expect(
- validateBoundedAgentConfig(
- config({
- boundedAgents: boundedAgents({
- privateRepos: [{ repo: 'https://github.com/octo/alpha', sensitivity: 'internal' }],
- }),
- }),
- env,
- ).join('\n'),
- ).toMatch(/bare owner\/repo slug/);
- });
-
- it('accepts sbx as a schema-level enclave runtime (capability-gated, not config-rejected)', () => {
- // sbx is fully schema-accepted at the configuration level: whether it is
- // actually usable is decided later by assertEnclaveRuntimeAvailable's
- // capability proof, never by blanket config rejection.
- expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime: 'sbx' }) }), env))
- .toEqual([]);
- });
-
- it('accepts every implemented and capability-gated backend', () => {
- for (const runtime of ['docker', 'gvisor', 'sbx'] as const) {
- expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime }) }), env))
- .toEqual([]);
- }
- });
-
- it('rejects an unknown enclave runtime name with no downgrade', () => {
- const errors = validateBoundedAgentConfig(
- config({ boundedAgents: boundedAgents({ runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] }) }),
- env,
- );
- expect(errors.join('\n')).toMatch(/"wasm" is not supported/);
- expect(errors.join('\n')).toMatch(/never downgrade/);
- });
-
- it('no longer rejects a primary sbx microVM at the config-validation level', () => {
- // The primary-agent runtime axis is proven independently by the
- // bounded-agent-specific assertPrimaryRuntimeAvailable, not blanket-rejected
- // here: a primary sbx microVM is supported once its bounded-agent ingress
- // is proven (see ./ingress.ts).
- expect(validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env)).toEqual([]);
- });
-
- it('rejects exposing the enclave Docker daemon to the primary agent', () => {
- const errors = validateBoundedAgentConfig(config({ enableDind: true }), env);
- expect(errors.join('\n')).toMatch(/cannot be combined with enableDind/);
- expect(errors.join('\n')).toMatch(/bypass the finite-disclosure ledger/);
- });
-
- it('rejects a non-Unix Docker host', () => {
- const errors = validateBoundedAgentConfig(config({ awfDockerHost: 'tcp://10.0.0.1:2375' }), env);
- expect(errors.join('\n')).toMatch(/Unix-socket Docker host/);
- });
-
- it('bounds every conservative resource and budget field', () => {
- const cases: Array<[Partial, RegExp]> = [
- [{ timeout: 0 }, /timeout must be a positive integer/],
- [{ timeout: 10_000 }, /timeout must be at most/],
- [{ maxInvocations: 0 }, /maxInvocations must be a positive integer/],
- [{ maxModelRequests: 0 }, /maxModelRequests must be a positive integer/],
- [{ maxModelTokens: 0 }, /maxModelTokens must be a positive integer/],
- [{ pidsLimit: 0 }, /pidsLimit must be a positive integer/],
- [{ maxOutputBytes: 0 }, /maxOutputBytes must be between/],
- [{ maxOutputBytes: 1_000_000 }, /maxOutputBytes must be between/],
- [{ maxTaskBytes: 0 }, /maxTaskBytes must be between/],
- [{ maxTaskBytes: 1_000_000 }, /maxTaskBytes must be between/],
- [{ memoryLimit: 'lots' }, /is not a Docker memory limit/],
- [{ tmpfsLimit: '64' }, /is not a Docker size limit/],
- [{ cpuLimit: 'all' }, /positive Docker --cpus value/],
- [{ cpuLimit: '0' }, /positive Docker --cpus value/],
- ];
- for (const [patch, matcher] of cases) {
- const errors = validateBoundedAgentConfig(
- config({ boundedAgents: boundedAgents(patch) }),
- env,
- );
- expect(errors.join('\n')).toMatch(matcher);
- }
- });
-});
-
-describe('resolveApiProxyRoute', () => {
- it('maps the native engine to its provider credential', () => {
- const selected = { engine: 'copilot', profile: 'openai' } as const;
- expect(resolveApiProxyRoute({ copilotGithubToken: 'k' } as WrapperConfig, selected).routed).toBe(true);
- expect(resolveApiProxyRoute({ openaiApiKey: 'k' } as WrapperConfig, selected).routed).toBe(false);
- });
-});
-
-describe('assertEnclaveRuntimeAvailable', () => {
- beforeEach(() => {
- mockExeca.mockReset();
- });
-
- it('uses Docker daemon probes by default', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: '{"runsc":{}}' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '27.0.0' });
-
- await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).resolves.toBeUndefined();
- await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'docker' }))).resolves.toBeUndefined();
- });
-
- it('fails closed when default Docker probes return malformed or unsuccessful results', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: '{' })
- .mockResolvedValueOnce({ exitCode: 1, stdout: '' });
-
- await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).rejects.toThrow(
- /runsc/,
- );
- await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'docker' }))).rejects.toThrow(
- /reachable Docker daemon/,
- );
- });
-
- it('rejects an unsuccessful default runtime query and unknown runtime', async () => {
- mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
- await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).rejects.toThrow(
- /runsc/,
- );
- await expect(
- assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'unknown' as BoundedAgentsConfig['runtime'] })),
- ).rejects.toThrow(/no implemented enclave launcher/);
- });
-
- it('accepts docker when the daemon is reachable', async () => {
- await expect(
- assertEnclaveRuntimeAvailable(boundedAgents(), async () => false, async () => true),
- ).resolves.toBeUndefined();
- });
-
- it('rejects docker when the daemon is unreachable, with no fallback', async () => {
- await expect(
- assertEnclaveRuntimeAvailable(boundedAgents(), async () => true, async () => false),
- ).rejects.toThrow(/never fall back/);
- });
-
- it('requires an exactly registered runsc for gvisor', async () => {
- const runtimes: string[] = [];
- await expect(
- assertEnclaveRuntimeAvailable(
- boundedAgents({ runtime: 'gvisor' }),
- async (name) => {
- runtimes.push(name);
- return true;
- },
- async () => true,
- ),
- ).resolves.toBeUndefined();
- expect(runtimes).toEqual(['runsc']);
- });
-
- it('never downgrades gvisor to the default runtime', async () => {
- await expect(
- assertEnclaveRuntimeAvailable(
- boundedAgents({ runtime: 'gvisor' }),
- async () => false,
- // Even a perfectly healthy default Docker runtime must not rescue this.
- async () => true,
- ),
- ).rejects.toThrow(/never fall back to a weaker runtime/);
- });
-
- it('accepts sbx when the capability probe reports full support', async () => {
- const querySbxCapabilities = jest.fn(async () => ({ supported: true, missing: [], auditedVersion: '0.37.1' }));
- await expect(
- assertEnclaveRuntimeAvailable(
- boundedAgents({ runtime: 'sbx' }),
- async () => true,
- async () => true,
- querySbxCapabilities,
- ),
- ).resolves.toBeUndefined();
- expect(querySbxCapabilities).toHaveBeenCalledTimes(1);
- });
-
- it('blocks sbx with the exact missing capabilities and never falls back, honestly reflecting audited 0.37.1', async () => {
- const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network'];
- await expect(
- assertEnclaveRuntimeAvailable(
- boundedAgents({ runtime: 'sbx' }),
- async () => true,
- async () => true,
- async () => ({ supported: false, missing, auditedVersion: '0.37.1' }),
- ),
- ).rejects.toThrow(/pinned AWF bounded-agent sbx template and bootstrap.*sbx create --network/);
- await expect(
- assertEnclaveRuntimeAvailable(
- boundedAgents({ runtime: 'sbx' }),
- async () => true,
- async () => true,
- async () => ({ supported: false, missing, auditedVersion: '0.37.1' }),
- ),
- ).rejects.toThrow(/never fall back to Docker or gVisor/);
- });
-
- it('rejects an unrecognized runtime with no implemented launcher', async () => {
- await expect(
- assertEnclaveRuntimeAvailable(
- { ...boundedAgents(), runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] },
- async () => true,
- async () => true,
- ),
- ).rejects.toThrow(/no implemented enclave launcher/);
- });
-});
-
-describe('assertPrimaryRuntimeAvailable', () => {
- it('is not the bounded-query implementation: bounded-agent errors must never leak bounded-query wording', () => {
- expect(assertPrimaryRuntimeAvailable).not.toBe(boundedQueryPreflight.assertPrimaryRuntimeAvailable);
- });
-
- it.each([
- [undefined, 'docker'],
- ['docker', 'docker'],
- ['gvisor', 'gvisor'],
- ['runsc', 'gvisor'],
- ['sbx', 'sbx'],
- ['kata', 'custom'],
- ] as const)('accepts an available %s primary backend (%s)', async (runtime, _backend) => {
- await expect(assertPrimaryRuntimeAvailable(
- runtime,
- jest.fn().mockResolvedValue(true),
- jest.fn().mockResolvedValue(true),
- jest.fn().mockResolvedValue(true),
- )).resolves.toBeUndefined();
- });
-
- it.each([
- [undefined, /Docker primary-agent runtime is unavailable/],
- ['docker', /OCI runtime "docker" is not registered.*never fall back/s],
- ['gvisor', /Primary-agent runtime "gvisor".*runsc.*never fall back/s],
- ['sbx', /Primary-agent runtime "sbx" is unavailable.*never fall back/s],
- ['kata', /OCI runtime "kata" is not registered.*never fall back/s],
- ] as const)('fails %s before staging when its primary capability is unavailable', async (runtime, message) => {
- await expect(assertPrimaryRuntimeAvailable(
- runtime,
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- )).rejects.toThrow(message);
- });
-
- it('always identifies failures as bounded-agent, never bounded-query', async () => {
- await expect(assertPrimaryRuntimeAvailable(
- undefined,
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- )).rejects.toThrow(/Bounded agents abort before staging/);
-
- let sbxError: Error | undefined;
- try {
- await assertPrimaryRuntimeAvailable(
- 'sbx',
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- );
- } catch (error) {
- sbxError = error as Error;
- }
- expect(sbxError?.message).toMatch(/Bounded agents abort before staging/);
- expect(sbxError?.message).not.toMatch(/[Bb]ounded quer(y|ies)/);
- });
-
- it('checks explicit docker runtime registration instead of Docker daemon availability', async () => {
- const runtimeQuery = jest.fn().mockResolvedValue(true);
- const dockerAvailable = jest.fn().mockResolvedValue(false);
- await expect(assertPrimaryRuntimeAvailable(
- 'docker',
- runtimeQuery,
- dockerAvailable,
- jest.fn().mockResolvedValue(true),
- )).resolves.toBeUndefined();
- expect(runtimeQuery).toHaveBeenCalledWith('docker');
- expect(dockerAvailable).not.toHaveBeenCalled();
- });
-});
diff --git a/src/bounded-agent/preflight.ts b/src/bounded-agent/preflight.ts
deleted file mode 100644
index 6cd35fb45..000000000
--- a/src/bounded-agent/preflight.ts
+++ /dev/null
@@ -1,405 +0,0 @@
-import type { BoundedAgentsConfig, WrapperConfig } from '../types';
-import {
- defaultDockerAvailabilityQuery,
- defaultDockerRuntimeQuery,
- defaultSbxAvailabilityQuery,
- type DockerAvailabilityQuery,
- type DockerRuntimeQuery,
- type SbxAvailabilityQuery,
-} from '../bounded-execution/runtime-probes';
-import { normalizeRepoKey } from './paths';
-import {
- BOUNDED_AGENT_REPO_PATTERN,
- MAX_BOUNDED_AGENT_TIMEOUT_SECONDS,
- MAX_RESULT_BYTES,
- MAX_TASK_BYTES,
-} from './protocol';
-import { resolveStagingToken } from '../bounded-query/staging';
-import {
- defaultBoundedAgentSbxCapabilityQuery,
- type BoundedAgentSbxCapabilityQuery,
-} from './sbx-capability';
-
-/**
- * Fail-closed preflight for bounded agents.
- *
- * JSON Schema already constrains the *shape* of `boundedAgents`. This module
- * covers everything the schema cannot: credential availability, the mandatory
- * API-proxy model route, sandbox runtime availability — for *both* the
- * primary agent and the bounded-agent enclave, evaluated as independent
- * matrix axes — and combinations of AWF settings under which a bounded agent
- * cannot be exposed securely.
- *
- * Every check here is fatal. A bounded-agent run that cannot satisfy its
- * isolation guarantees must abort before the primary agent starts and before
- * any repository is staged, rather than silently downgrading — in
- * particular, an unavailable `runsc` never falls back to the default Docker
- * runtime, and the `sbx` enclave backend never falls back to Docker or
- * gVisor when its capability proof fails (which it always currently does;
- * see `./sbx-capability.ts`).
- *
- * The *primary* agent runtime is a completely separate axis from the
- * *enclave* runtime: a primary `sbx` microVM can be paired with a `docker` or
- * `gvisor` bounded-agent enclave once the primary-sbx ingress is proven (see
- * `./ingress.ts`), and a `docker`/`gvisor` primary can never be paired with a
- * `sbx` enclave while sbx's capability report is incomplete. See
- * `./runtime-matrix.ts` for the full evaluation of all nine combinations.
- */
-
-/** Enclave runtimes with a safe, implemented launcher. */
-const IMPLEMENTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor']);
-
-/** Every enclave runtime the schema accepts, implemented or capability-gated. */
-const SUPPORTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']);
-
-/** Native engine adapters with a published, audited enclave image. */
-const IMPLEMENTED_ENGINES = new Set(['copilot']);
-
-/** Docker OCI runtime name required for the `gvisor` enclave runtime. */
-const GVISOR_DOCKER_RUNTIME = 'runsc';
-
-export type {
- DockerAvailabilityQuery,
- DockerRuntimeQuery,
- SbxAvailabilityQuery,
-} from '../bounded-execution/runtime-probes';
-
-type PrimaryRuntimeCase = 'sbx' | 'docker' | 'gvisor' | 'custom' | 'default-docker';
-
-function classifyPrimaryRuntime(runtime: string | undefined): PrimaryRuntimeCase {
- if (runtime === 'sbx') return 'sbx';
- if (runtime === 'docker') return 'docker';
- if (runtime === 'gvisor' || runtime === 'runsc') return 'gvisor';
- if (runtime) return 'custom';
- return 'default-docker';
-}
-
-/**
- * Resolves whether the configured profile has a usable API-proxy model route.
- *
- * A bounded agent has no credentials of its own: it can only reach a model
- * through the AWF API proxy, which injects the real key. If the profile's
- * provider is not routed by the sidecar for this run, the enclave would sit on
- * an internal network with nothing to talk to — so the run is rejected rather
- * than started in a state where every invocation would return the canonical
- * error.
- */
-export function resolveApiProxyRoute(
- config: WrapperConfig,
- boundedAgents: Pick,
-): { routed: boolean; detail: string } {
- if (boundedAgents.engine === 'copilot') {
- return {
- routed: Boolean(
- config.copilotGithubToken
- || config.copilotProviderApiKey
- || config.copilotProviderBaseUrl
- ),
- detail: 'apiProxy.targets.copilot (COPILOT_GITHUB_TOKEN or Copilot BYOK route) is not configured',
- };
- }
- if (boundedAgents.profile === 'anthropic') {
- return {
- routed: Boolean(config.anthropicApiKey),
- detail: 'apiProxy.targets.anthropic (ANTHROPIC_API_KEY) is not configured',
- };
- }
- return {
- routed: Boolean(config.openaiApiKey),
- detail: 'apiProxy.targets.openai (OPENAI_API_KEY) is not configured',
- };
-}
-
-/** Parses a Docker-style size string (e.g. `512m`) into bytes. */
-function isDockerSize(value: string): boolean {
- return /^[1-9][0-9]*[bkmgBKMG]$/.test(value);
-}
-
-/**
- * Validates everything about a bounded-agent configuration that can be decided
- * without touching Docker or the network.
- *
- * @returns human-readable errors; empty when the configuration is acceptable.
- */
-export function validateBoundedAgentConfig(
- config: WrapperConfig,
- env: NodeJS.ProcessEnv = process.env,
-): string[] {
- const boundedAgents = config.boundedAgents;
- if (!boundedAgents?.enabled) return [];
-
- const errors: string[] = [];
-
- // The primary-agent runtime (docker / gvisor / sbx) is validated as its own
- // matrix axis by assertPrimaryRuntimeAvailable, not rejected here. A primary
- // sbx microVM is supported once its bounded-agent ingress is proven (see
- // ./ingress.ts and ./manager.ts).
-
- if (config.enableDind) {
- errors.push(
- 'bounded agents cannot be combined with enableDind: exposing the Docker socket to the primary ' +
- 'agent would allow it to inspect credentials, mount private seeds, join enclave networks, and ' +
- 'bypass the finite-disclosure ledger',
- );
- }
-
- if (boundedAgents.privateRepos.length === 0) {
- errors.push('boundedAgents.enabled is true but boundedAgents.privateRepos is empty');
- }
-
- const seenKeys = new Set();
- for (const entry of boundedAgents.privateRepos) {
- const repo = entry.repo;
- if (!BOUNDED_AGENT_REPO_PATTERN.test(repo)) {
- errors.push(
- `boundedAgents.privateRepos entry "${repo}" is not a bare owner/repo slug ` +
- '(no scheme, host, credentials, path traversal, query, fragment, or wildcard)',
- );
- continue;
- }
- const key = normalizeRepoKey(repo);
- if (seenKeys.has(key)) {
- errors.push(`boundedAgents.privateRepos contains a duplicate entry: "${repo}"`);
- }
- seenKeys.add(key);
- }
-
- if (!SUPPORTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) {
- errors.push(
- `boundedAgents.runtime "${boundedAgents.runtime}" is not supported. ` +
- 'AWF has no audited, single-use, API-proxy-only enclave launcher for it, and bounded agents ' +
- 'never downgrade to a weaker runtime. Use "docker", "gvisor", or "sbx".',
- );
- }
-
- if (!IMPLEMENTED_ENGINES.has(boundedAgents.engine)) {
- errors.push(
- `boundedAgents.engine "${boundedAgents.engine}" is not implemented. ` +
- 'Only "copilot" currently has a pinned native CLI enclave image; AWF never falls back to a different engine.',
- );
- }
-
- if (!config.enableApiProxy) {
- errors.push(
- 'bounded agents require the AWF API proxy to be enabled: the enclave holds no credentials and ' +
- 'the API proxy is its only permitted upstream egress',
- );
- }
-
- if (!boundedAgents.model || boundedAgents.model.length === 0) {
- errors.push('boundedAgents.model is required when boundedAgents.enabled is true');
- }
-
- const route = resolveApiProxyRoute(config, boundedAgents);
- if (!route.routed) {
- errors.push(
- `bounded agents require a supported configured API target for engine "${boundedAgents.engine}": ` +
- `${route.detail}`,
- );
- }
-
- // Reserve the final minute of the 10-minute response bucket for Docker
- // termination, result validation, container removal, and workspace cleanup.
- if (!Number.isInteger(boundedAgents.timeout) || boundedAgents.timeout < 1) {
- errors.push('boundedAgents.timeout must be a positive integer number of seconds');
- } else if (boundedAgents.timeout > MAX_BOUNDED_AGENT_TIMEOUT_SECONDS) {
- errors.push(
- `boundedAgents.timeout must be at most ${MAX_BOUNDED_AGENT_TIMEOUT_SECONDS} seconds ` +
- '(the 10-minute response bucket reserves its final minute for termination, validation, and cleanup)',
- );
- }
-
- if (!Number.isInteger(boundedAgents.maxInvocations) || boundedAgents.maxInvocations < 1) {
- errors.push('boundedAgents.maxInvocations must be a positive integer');
- }
- if (!Number.isInteger(boundedAgents.maxModelRequests) || boundedAgents.maxModelRequests < 1) {
- errors.push('boundedAgents.maxModelRequests must be a positive integer');
- }
- if (!Number.isInteger(boundedAgents.maxModelTokens) || boundedAgents.maxModelTokens < 1) {
- errors.push('boundedAgents.maxModelTokens must be a positive integer');
- }
- if (!Number.isInteger(boundedAgents.pidsLimit) || boundedAgents.pidsLimit < 1) {
- errors.push('boundedAgents.pidsLimit must be a positive integer');
- }
- if (
- !Number.isInteger(boundedAgents.maxOutputBytes)
- || boundedAgents.maxOutputBytes < 1
- || boundedAgents.maxOutputBytes > MAX_RESULT_BYTES
- ) {
- errors.push(`boundedAgents.maxOutputBytes must be between 1 and ${MAX_RESULT_BYTES}`);
- }
- if (
- !Number.isInteger(boundedAgents.maxTaskBytes)
- || boundedAgents.maxTaskBytes < 1
- || boundedAgents.maxTaskBytes > MAX_TASK_BYTES
- ) {
- errors.push(`boundedAgents.maxTaskBytes must be between 1 and ${MAX_TASK_BYTES}`);
- }
-
- if (!isDockerSize(boundedAgents.memoryLimit)) {
- errors.push(`boundedAgents.memoryLimit "${boundedAgents.memoryLimit}" is not a Docker memory limit`);
- }
- if (!isDockerSize(boundedAgents.tmpfsLimit)) {
- errors.push(`boundedAgents.tmpfsLimit "${boundedAgents.tmpfsLimit}" is not a Docker size limit`);
- }
- if (!/^(?:[0-9]{1,2})(?:\.[0-9]{1,3})?$/.test(boundedAgents.cpuLimit) || Number(boundedAgents.cpuLimit) <= 0) {
- errors.push(`boundedAgents.cpuLimit "${boundedAgents.cpuLimit}" is not a positive Docker --cpus value`);
- }
-
- // The broker/enclave subsystem always runs via Docker Compose (Squid and
- // the API proxy are always compose services), independent of the primary
- // agent's own runtime. sbx *enclave* runtime is exempted because the sbx
- // enclave runner never mounts the Docker socket into the broker at all —
- // see buildBoundedAgentService and containers/bounded-agent/broker/config.js.
- const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST;
- if (boundedAgents.runtime !== 'sbx' && dockerHost && !dockerHost.startsWith('unix://')) {
- errors.push(
- `bounded agents require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` +
- 'The broker has no route to a TCP daemon and AWF will not weaken that isolation.',
- );
- }
-
- if (!resolveStagingToken(env)) {
- errors.push(
- 'bounded agents require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host ' +
- '(it is used only by the trusted staging phase and never reaches the agent, broker, or enclave)',
- );
- }
-
- return errors;
-}
-
-/**
- * Verifies that the requested enclave runtime is actually available.
- *
- * Only reached after {@link validateBoundedAgentConfig} accepted the runtime
- * name, so the only remaining question is capability. gVisor requires an
- * exact `runsc` registration and is never downgraded. The `sbx` enclave
- * backend runs a full capability proof — {@link defaultBoundedAgentSbxCapabilityQuery}
- * — and is blocked with the exact missing controls whenever the proof is
- * incomplete, which it always currently is for audited sbx 0.37.1.
- */
-export async function assertEnclaveRuntimeAvailable(
- boundedAgents: BoundedAgentsConfig,
- queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery,
- queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery,
- querySbxCapabilities: BoundedAgentSbxCapabilityQuery = defaultBoundedAgentSbxCapabilityQuery,
-): Promise {
- if (boundedAgents.runtime === 'gvisor') {
- if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) {
- throw new Error(
- `boundedAgents.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` +
- 'registered with the Docker daemon. It is not available, and bounded agents never fall back ' +
- 'to a weaker runtime.',
- );
- }
- return;
- }
-
- if (boundedAgents.runtime === 'docker') {
- if (!(await queryDockerAvailable())) {
- throw new Error(
- 'boundedAgents.runtime "docker" requires a reachable Docker daemon. It is not available, ' +
- 'and bounded agents never fall back to another runtime.',
- );
- }
- return;
- }
-
- if (boundedAgents.runtime === 'sbx') {
- const report = await querySbxCapabilities();
- if (!report.supported) {
- throw new Error(
- 'boundedAgents.runtime "sbx" is blocked because the installed sbx runtime cannot enforce all ' +
- `mandatory enclave-isolation controls: ${report.missing.join(', ')}. ` +
- 'AWF will not launch an enclave VM and will never fall back to Docker or gVisor.',
- );
- }
- return;
- }
-
- throw new Error(
- `boundedAgents.runtime "${boundedAgents.runtime}" has no implemented enclave launcher. ` +
- 'Bounded agents fail closed rather than downgrading to Docker or gVisor.',
- );
-}
-
-/**
- * Verifies the primary-agent runtime before bounded-agent repository staging.
- *
- * The primary-agent runtime (`docker` / `gvisor` / `sbx`, independent of the
- * `boundedAgents.runtime` enclave axis) is proven by a bounded-agent-specific
- * check with bounded-agent wording in every failure, rather than reusing
- * bounded queries' implementation: reusing it would surface bounded-query
- * error text (e.g. "Bounded queries abort before staging") on a run that may
- * not even have bounded queries enabled. The underlying capability probes
- * (Docker runtime registration, Docker daemon reachability, sbx CLI/daemon
- * reachability) are identical in substance to bounded queries' own primary
- * check; only the identifying language differs. Bounded agents and bounded
- * queries can be enabled independently or together, and neither ever falls
- * back to a weaker runtime.
- */
-export async function assertPrimaryRuntimeAvailable(
- containerRuntime: string | undefined,
- queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery,
- queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery,
- querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery,
-): Promise {
- const runtimeCase = classifyPrimaryRuntime(containerRuntime);
- switch (runtimeCase) {
- case 'sbx':
- if (!(await querySbxAvailable())) {
- throw new Error(
- 'Primary-agent runtime "sbx" is unavailable. Bounded agents abort before staging and never ' +
- 'fall back to a Docker or gVisor primary agent.',
- );
- }
- return;
- case 'docker':
- if (!(await queryDockerRuntime('docker'))) {
- throw new Error(
- 'Primary-agent OCI runtime "docker" is not registered with Docker. ' +
- 'Bounded agents abort before staging and never fall back.',
- );
- }
- return;
- case 'gvisor':
- if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) {
- throw new Error(
- `Primary-agent runtime "${containerRuntime}" requires the "${GVISOR_DOCKER_RUNTIME}" OCI ` +
- 'runtime. It is not available, so bounded agents abort before staging and never fall back.',
- );
- }
- return;
- case 'custom':
- if (!(await queryDockerRuntime(containerRuntime!))) {
- throw new Error(
- `Primary-agent OCI runtime "${containerRuntime}" is not registered with Docker. ` +
- 'Bounded agents abort before staging and never fall back.',
- );
- }
- return;
- case 'default-docker':
- if (!(await queryDockerAvailable())) {
- throw new Error(
- 'The Docker primary-agent runtime is unavailable. ' +
- 'Bounded agents abort before staging and never fall back.',
- );
- }
- return;
- default:
- throw new Error(`Unreachable primary runtime case: ${runtimeCase satisfies never}`);
- }
-}
-
-/** @internal Exported for focused unit tests. */
-// ts-prune-ignore-next
-export const boundedAgentPreflightTestHelpers = {
- IMPLEMENTED_ENCLAVE_RUNTIMES,
- SUPPORTED_ENCLAVE_RUNTIMES,
- GVISOR_DOCKER_RUNTIME,
- defaultDockerRuntimeQuery,
- defaultDockerAvailabilityQuery,
- defaultSbxAvailabilityQuery,
- isDockerSize,
-};
diff --git a/src/bounded-agent/protocol.test.ts b/src/bounded-agent/protocol.test.ts
deleted file mode 100644
index a7ea53347..000000000
--- a/src/bounded-agent/protocol.test.ts
+++ /dev/null
@@ -1,295 +0,0 @@
-import * as path from 'path';
-import {
- AGENT_PROTOCOL_VERSION,
- ALLOWED_REQUEST_KEYS,
- CANONICAL_ERROR_JSON,
- FORBIDDEN_REQUEST_KEYS,
- MAX_PRIVATE_REPO_LENGTH,
- MAX_RESULT_BYTES,
- MAX_SCHEMA_BYTES,
- MAX_TASK_BYTES,
- RESULT_STATUS_BIT_COST,
- TIMING_BUCKETS_MS,
- TIMING_BUCKET_BITS,
- canonicalOkJson,
- canonicalizeSchemaValue,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
- schemaCardinality,
- strictParseJson,
- validateSchema,
- validateValueAgainstSchema,
- validateBoundedAgentRequest,
- type BoundedAgentSchemaNode,
-} from './protocol';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const brokerFraming = require(path.join(brokerDir, 'framing.js'));
-const brokerProtocol = require(path.join(brokerDir, 'protocol.js'));
-const brokerSpec = require(path.join(brokerDir, 'enclave-runner-spec.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const booleanSchema: BoundedAgentSchemaNode = { type: 'boolean' };
-
-const request = (overrides: Record = {}): Record => ({
- privateRepo: 'octo/alpha',
- schema: booleanSchema,
- task: 'Does this repository declare a SECURITY.md?',
- ...overrides,
-});
-
-/**
- * Request-protocol coverage.
- *
- * A bounded-agent request may select only a configured repository, declare a
- * finite result schema, and carry bounded task text. Everything else — image,
- * command, executable, mount, environment, endpoint, network, proxy,
- * credential, timeout, resource limit, runtime, tool definition — and any
- * unknown key must be rejected.
- */
-describe('validateBoundedAgentRequest', () => {
- it('accepts the only permitted request shape', () => {
- const result = validateBoundedAgentRequest(request());
- expect(result.valid).toBe(true);
- });
-
- it('exposes exactly three allowed keys', () => {
- expect([...ALLOWED_REQUEST_KEYS].sort()).toEqual(['privateRepo', 'schema', 'task']);
- });
-
- it.each(FORBIDDEN_REQUEST_KEYS)('rejects the forbidden control "%s"', (key) => {
- const result = validateBoundedAgentRequest(request({ [key]: 'anything' }));
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.join('\n')).toContain(`request may not specify "${key}"`);
- }
- });
-
- it('names every capability class the design forbids', () => {
- for (const key of [
- 'image', 'command', 'executable', 'mounts', 'env', 'endpoint', 'network', 'proxy',
- 'credentials', 'timeout', 'resources', 'runtime', 'tools', 'model', 'profile', 'systemPrompt',
- ]) {
- expect(FORBIDDEN_REQUEST_KEYS).toContain(key);
- }
- });
-
- it('rejects unknown keys', () => {
- const result = validateBoundedAgentRequest(request({ somethingNew: 1 }));
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.join('\n')).toContain('unknown request key: "somethingNew"');
- }
- });
-
- it('rejects non-object requests', () => {
- for (const raw of [undefined, null, 'x', 42, []]) {
- expect(validateBoundedAgentRequest(raw).valid).toBe(false);
- }
- });
-
- it('rejects repository selectors that are not a bare owner/repo slug', () => {
- for (const privateRepo of [
- 'https://github.com/octo/alpha',
- 'octo/alpha/../secret',
- '../octo/alpha',
- 'octo',
- 42,
- ]) {
- expect(validateBoundedAgentRequest(request({ privateRepo })).valid).toBe(false);
- }
- expect(
- validateBoundedAgentRequest(request({ privateRepo: `a/${'b'.repeat(MAX_PRIVATE_REPO_LENGTH)}` })).valid,
- ).toBe(false);
- });
-
- it('rejects a non-finite result schema', () => {
- for (const schema of [
- { type: 'string' },
- { type: 'number' },
- { $ref: '#/definitions/self' },
- { type: 'object', fields: { a: { type: 'string' } } },
- { type: 'integer' },
- ]) {
- expect(validateBoundedAgentRequest(request({ schema })).valid).toBe(false);
- }
- });
-
- it('byte-bounds the task text against the configured and hard limits', () => {
- expect(validateBoundedAgentRequest(request({ task: '' })).valid).toBe(false);
- expect(validateBoundedAgentRequest(request({ task: 42 })).valid).toBe(false);
- expect(
- validateBoundedAgentRequest(request({ task: 'x'.repeat(100) }), { maxTaskBytes: 50 }).valid,
- ).toBe(false);
- expect(
- validateBoundedAgentRequest(request({ task: 'x'.repeat(50) }), { maxTaskBytes: 50 }).valid,
- ).toBe(true);
- expect(
- validateBoundedAgentRequest(request({ task: 'x'.repeat(MAX_TASK_BYTES + 1) }), {
- maxTaskBytes: MAX_TASK_BYTES * 10,
- }).valid,
- ).toBe(false);
- });
-
- it('counts task size in UTF-8 bytes, not code units', () => {
- // "€" is 3 bytes.
- expect(validateBoundedAgentRequest(request({ task: '€€' }), { maxTaskBytes: 5 }).valid).toBe(false);
- expect(validateBoundedAgentRequest(request({ task: '€€' }), { maxTaskBytes: 6 }).valid).toBe(true);
- });
-});
-
-describe('canonical envelopes and timing buckets (PR1 primitives)', () => {
- it('re-exports the complete finite-disclosure boundary', () => {
- expect(MAX_RESULT_BYTES).toBeGreaterThan(0);
- expect(MAX_SCHEMA_BYTES).toBeGreaterThan(0);
- expect(MAX_PRIVATE_REPO_LENGTH).toBeGreaterThan(0);
- expect(schemaCardinality({ type: 'boolean' })).toBe(2n);
- expect(strictParseJson('true')).toEqual({ value: true });
- expect(validateSchema(booleanSchema).valid).toBe(true);
- expect(validateValueAgainstSchema(booleanSchema, true)).toBe(true);
- expect(canonicalizeSchemaValue(booleanSchema, true)).toBe('true');
- });
-
- it('uses the shared canonical error shape', () => {
- expect(CANONICAL_ERROR_JSON).toBe('{"status":"error"}');
- });
-
- it('uses the shared canonical success shape', () => {
- expect(canonicalOkJson('true')).toBe('{"status":"ok","result":true}');
- });
-
- it('uses the shared fixed timing buckets', () => {
- expect(TIMING_BUCKETS_MS).toEqual([10, 100, 1_000, 10_000, 60_000, 600_000]);
- expect(TIMING_BUCKET_BITS).toBe(3);
- });
-
- it('charges the status and timing channels in addition to the schema payload', () => {
- // boolean => 1 payload bit
- expect(queryBitsForSchema(booleanSchema)).toBe(RESULT_STATUS_BIT_COST + 1 + TIMING_BUCKET_BITS);
- // const => 0 payload bits, still charged for status + timing
- expect(queryBitsForSchema({ type: 'const', value: 'x' })).toBe(
- RESULT_STATUS_BIT_COST + TIMING_BUCKET_BITS,
- );
- });
-
- it('validates and canonicalizes enclave output against the declared schema', () => {
- expect(parseAndValidateQueryOutput('true', booleanSchema)).toEqual({ ok: true, canonical: 'true' });
- expect(parseAndValidateQueryOutput('"true"', booleanSchema).ok).toBe(false);
- expect(parseAndValidateQueryOutput('', booleanSchema).ok).toBe(false);
- expect(parseAndValidateQueryOutput('true true', booleanSchema).ok).toBe(false);
- });
-});
-
-/**
- * The broker runs in its own container image and cannot import AWF's
- * TypeScript sources. These checks fail the moment the two implementations of
- * the bounded-agent request contract disagree.
- */
-describe('TypeScript ↔ broker parity', () => {
- it('shares the PR1 canonical envelopes and buckets', () => {
- expect(brokerProtocol.CANONICAL_ERROR_JSON).toBe(CANONICAL_ERROR_JSON);
- expect(brokerProtocol.TIMING_BUCKETS_MS).toEqual([...TIMING_BUCKETS_MS]);
- expect(brokerProtocol.canonicalOkJson('true')).toBe(canonicalOkJson('true'));
- });
-
- it('agrees on the framing protocol version', () => {
- expect(brokerFraming.AGENT_PROTOCOL_VERSION).toBe(String(AGENT_PROTOCOL_VERSION));
- });
-
- it('agrees on the allowed and forbidden request keys', () => {
- expect([...brokerFraming.ALLOWED_REQUEST_KEYS].sort()).toEqual([...ALLOWED_REQUEST_KEYS].sort());
- expect([...brokerFraming.FORBIDDEN_REQUEST_KEYS].sort()).toEqual([...FORBIDDEN_REQUEST_KEYS].sort());
- });
-
- it('agrees on accept/reject for a shared vector table', () => {
- const vectors: Array | unknown> = [
- request(),
- request({ task: '' }),
- request({ privateRepo: 'octo' }),
- request({ schema: { type: 'string' } }),
- request({ image: 'evil' }),
- request({ tools: [] }),
- request({ nope: true }),
- 'not an object',
- null,
- ];
- for (const vector of vectors) {
- const ts = validateBoundedAgentRequest(vector, { maxTaskBytes: 4096 });
- const broker = brokerFraming.validateBoundedAgentRequest(vector, { maxTaskBytes: 4096 });
- expect(broker.valid).toBe(ts.valid);
- }
- });
-
- it('agrees on the schema information charge for a shared vector table', () => {
- const schemas: BoundedAgentSchemaNode[] = [
- { type: 'boolean' },
- { type: 'const', value: 1 },
- { type: 'enum', values: ['a', 'b', 'c'] },
- { type: 'integer', minimum: 0, maximum: 255 },
- { type: 'object', fields: [
- { name: 'a', schema: { type: 'boolean' } },
- { name: 'b', schema: { type: 'boolean' } },
- ] },
- ];
- for (const schema of schemas) {
- expect(brokerProtocol.queryBitsForSchema(schema)).toBe(queryBitsForSchema(schema));
- }
- });
-});
-
-describe('broker request framing', () => {
- const headers = {
- 'x-awf-agent-version': '1',
- 'x-awf-repo': 'octo/alpha',
- 'x-awf-schema-b64': Buffer.from(JSON.stringify(booleanSchema), 'utf8').toString('base64url'),
- };
- const rawHeaders = Object.entries(headers).flat();
-
- it('assembles the canonical request from fixed headers plus the task body', () => {
- const framed = brokerFraming.buildRequestFromFrame(headers, rawHeaders, 'task text');
- expect(framed.error).toBeUndefined();
- expect(framed.request).toEqual({
- privateRepo: 'octo/alpha',
- schema: booleanSchema,
- task: 'task text',
- });
- });
-
- it('rejects any other x-awf control header', () => {
- const extra = { ...headers, 'x-awf-runtime': 'runc' };
- const framed = brokerFraming.buildRequestFromFrame(
- extra,
- Object.entries(extra).flat(),
- 'task',
- );
- expect(framed.error).toMatch(/unsupported request control header/);
- });
-
- it('rejects duplicated control headers', () => {
- const duplicated = [...rawHeaders, 'x-awf-repo', 'octo/beta'];
- const framed = brokerFraming.buildRequestFromFrame(headers, duplicated, 'task');
- expect(framed.error).toMatch(/duplicate request header/);
- });
-
- it('rejects an unsupported protocol version', () => {
- const bad = { ...headers, 'x-awf-agent-version': '2' };
- expect(
- brokerFraming.buildRequestFromFrame(bad, Object.entries(bad).flat(), 'task').error,
- ).toMatch(/protocol version/);
- });
-
- it('rejects a malformed schema header', () => {
- const bad = { ...headers, 'x-awf-schema-b64': 'not+base64url/' };
- expect(
- brokerFraming.buildRequestFromFrame(bad, Object.entries(bad).flat(), 'task').error,
- ).toMatch(/schema header/);
- });
-});
-
-describe('enclave runner spec identifiers', () => {
- it('uses a bounded-agent-specific run label distinct from bounded queries', () => {
- expect(brokerSpec.RUN_LABEL).toBe('awf.bounded-agent.run');
- expect(brokerSpec.INVOCATION_LABEL).toBe('awf.bounded-agent.invocation');
- });
-});
diff --git a/src/bounded-agent/protocol.ts b/src/bounded-agent/protocol.ts
deleted file mode 100644
index bf4717d55..000000000
--- a/src/bounded-agent/protocol.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-/**
- * Bounded-agent request/result protocol.
- *
- * The wire *algebra* — the finite response schema, its cardinality and
- * information charge, strict JSON parsing, canonicalization, timing buckets,
- * and the canonical success/error envelopes — is the PR1 bounded-execution
- * foundation in `src/bounded-execution/finite-disclosure.ts`. This module adds
- * only what is specific to a bounded *agent* request:
- *
- * - the request selects a configured repository, declares a finite result
- * schema, and carries a byte-bounded task text; nothing else;
- * - every control a caller might try to smuggle in — image, command,
- * executable, mount, environment, endpoint, network, proxy, credential,
- * timeout, resource limit, runtime, or tool definition — is explicitly
- * rejected, as is any unknown key.
- *
- * Rejecting *explicitly named* controls in addition to the generic
- * unknown-key rule is redundant by construction; it is kept because it turns a
- * future accidental widening of the accepted key set into a test failure
- * rather than a silent capability grant.
- */
-
-import {
- MAX_PRIVATE_REPO_LENGTH,
- BOUNDED_QUERY_REPO_PATTERN,
- validateSchema,
- type BoundedQuerySchemaNode,
-} from '../bounded-execution/finite-disclosure';
-
-export {
- CANONICAL_ERROR_JSON,
- MAX_RESULT_BYTES,
- MAX_SCHEMA_BYTES,
- MAX_QUERY_TIMEOUT_SECONDS as MAX_BOUNDED_AGENT_TIMEOUT_SECONDS,
- RESULT_STATUS_BIT_COST,
- TIMING_BUCKETS_MS,
- TIMING_BUCKET_BITS,
- BOUNDED_QUERY_REPO_PATTERN as BOUNDED_AGENT_REPO_PATTERN,
- MAX_PRIVATE_REPO_LENGTH,
- canonicalOkJson,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
- schemaCardinality,
- strictParseJson,
- validateSchema,
- validateValueAgainstSchema,
- canonicalizeSchemaValue,
- type BoundedQuerySchemaNode as BoundedAgentSchemaNode,
-} from '../bounded-execution/finite-disclosure';
-
-/** Framing/protocol version of the bounded-agent request contract. */
-export const AGENT_PROTOCOL_VERSION = 1;
-
-/** Hard ceiling on the caller-supplied task text, independent of configuration. */
-export const MAX_TASK_BYTES = 64 * 1024;
-
-/** The complete set of keys a bounded-agent request may contain. */
-export const ALLOWED_REQUEST_KEYS: readonly string[] = ['privateRepo', 'schema', 'task'];
-
-/**
- * Every accepted spelling of the single free-form payload field.
- *
- * Exactly one is accepted per caller surface (`task` for the legacy
- * bounded-agent wrapper protocol, `prompt` for the unified enclave MCP tool);
- * the other is an explicitly forbidden control so a request can never smuggle
- * a second payload past the finite-disclosure charge.
- */
-export const PAYLOAD_REQUEST_KEYS: readonly string[] = ['task', 'prompt'];
-
-/** The payload spelling this legacy bounded-agent protocol accepts. */
-const PAYLOAD_KEY = 'task';
-
-/** The alternate payload spellings this surface must reject. */
-const FORBIDDEN_PAYLOAD_KEYS = PAYLOAD_REQUEST_KEYS.filter((key) => key !== PAYLOAD_KEY);
-
-/**
- * Controls a request may never express.
- *
- * These are all fixed trusted configuration. Naming them explicitly makes the
- * rejection self-documenting and testable; the generic unknown-key rule below
- * would reject them anyway.
- */
-export const FORBIDDEN_REQUEST_KEYS: readonly string[] = [
- 'image',
- 'images',
- 'command',
- 'cmd',
- 'args',
- 'argv',
- 'entrypoint',
- 'executable',
- 'interpreter',
- 'script',
- 'shell',
- 'mount',
- 'mounts',
- 'volume',
- 'volumes',
- 'bind',
- 'path',
- 'paths',
- 'workdir',
- 'env',
- 'environment',
- 'endpoint',
- 'endpoints',
- 'baseUrl',
- 'url',
- 'host',
- 'network',
- 'networks',
- 'dns',
- 'proxy',
- 'httpProxy',
- 'httpsProxy',
- 'credential',
- 'credentials',
- 'apiKey',
- 'token',
- 'authorization',
- 'headers',
- 'timeout',
- 'timeoutSeconds',
- 'deadline',
- 'memory',
- 'memoryLimit',
- 'cpu',
- 'cpuLimit',
- 'pids',
- 'pidsLimit',
- 'tmpfs',
- 'ulimit',
- 'resources',
- 'runtime',
- 'backend',
- 'engine',
- 'sandbox',
- 'profile',
- 'model',
- 'provider',
- 'temperature',
- 'maxTokens',
- 'maxModelRequests',
- 'tool',
- 'tools',
- 'toolChoice',
- 'functions',
- 'systemPrompt',
- 'system',
- 'messages',
- ...FORBIDDEN_PAYLOAD_KEYS,
-];
-
-/** A validated bounded-agent request. */
-export interface BoundedAgentRequest {
- /** Configured repository selector, in `owner/repo` form. */
- privateRepo: string;
- /** Finite response schema the enclave's answer must conform to. */
- schema: BoundedQuerySchemaNode;
- /** Byte-bounded task text, forwarded verbatim into the enclave prompt. */
- task: string;
-}
-
-export type BoundedAgentValidation =
- | { valid: true; request: BoundedAgentRequest }
- | { valid: false; errors: string[] };
-
-/** Options bounding a request against the *run's* normalized configuration. */
-export interface ValidateBoundedAgentRequestOptions {
- /** Configured `maxTaskBytes`. Clamped to {@link MAX_TASK_BYTES}. */
- maxTaskBytes?: number;
-}
-
-function isPlainObject(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-/**
- * Validates a bounded-agent request against the fixed protocol.
- *
- * Fails closed: any structural surprise (unknown key, forbidden control,
- * oversized task, non-finite schema, malformed repository selector) produces
- * an invalid result whose errors are only ever written to the protected audit
- * log, never returned to the caller.
- */
-export function validateBoundedAgentRequest(
- raw: unknown,
- options: ValidateBoundedAgentRequestOptions = {},
-): BoundedAgentValidation {
- const errors: string[] = [];
- if (!isPlainObject(raw)) {
- return { valid: false, errors: ['request must be a JSON object'] };
- }
-
- const forbidden = FORBIDDEN_REQUEST_KEYS.filter((key) =>
- Object.prototype.hasOwnProperty.call(raw, key));
- for (const key of forbidden) {
- errors.push(`request may not specify "${key}"`);
- }
- for (const key of Object.keys(raw)) {
- if (!ALLOWED_REQUEST_KEYS.includes(key) && !forbidden.includes(key)) {
- errors.push(`unknown request key: "${key}"`);
- }
- }
-
- const { privateRepo, schema, task } = raw as Partial;
-
- if (typeof privateRepo !== 'string') {
- errors.push('privateRepo must be a string');
- } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH) {
- errors.push('privateRepo exceeds the maximum length');
- } else if (!BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) {
- errors.push('privateRepo must be a bare owner/repo slug');
- }
-
- const schemaValidation = validateSchema(schema);
- if (!schemaValidation.valid) {
- errors.push(...schemaValidation.errors);
- }
-
- const taskLimit = Math.min(
- Number.isInteger(options.maxTaskBytes) && (options.maxTaskBytes as number) > 0
- ? (options.maxTaskBytes as number)
- : MAX_TASK_BYTES,
- MAX_TASK_BYTES,
- );
- if (typeof task !== 'string') {
- errors.push('task must be a string');
- } else if (task.length === 0) {
- errors.push('task must not be empty');
- } else if (Buffer.byteLength(task, 'utf8') > taskLimit) {
- errors.push('task exceeds the maximum size');
- }
-
- if (errors.length > 0) return { valid: false, errors };
-
- return {
- valid: true,
- request: {
- privateRepo: privateRepo as string,
- schema: schemaValidation.valid ? schemaValidation.schema : (schema as BoundedQuerySchemaNode),
- task: task as string,
- },
- };
-}
diff --git a/src/bounded-agent/runtime-matrix.test.ts b/src/bounded-agent/runtime-matrix.test.ts
deleted file mode 100644
index 8f52fecd3..000000000
--- a/src/bounded-agent/runtime-matrix.test.ts
+++ /dev/null
@@ -1,331 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import {
- BOUNDED_AGENT_RUNTIME_BACKENDS,
- evaluateBoundedAgentRuntimeCombination,
- evaluateBoundedAgentRuntimeMatrix,
- resolveBoundedAgentPrimaryBackend,
- serializeBoundedAgentRuntimeTelemetry,
- type BoundedAgentPrimaryBackend,
- type BoundedAgentRuntimeCapabilities,
-} from './runtime-matrix';
-import type { BoundedAgentRuntime } from '../types';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const { createBroker } = require(path.join(brokerDir, 'broker.js'));
-const { createRuntimeTelemetry } = require(path.join(brokerDir, 'runtime-telemetry.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const CANONICAL_ERROR = '{"status":"error"}';
-const PRIMARY_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS;
-const BOUNDED_AGENT_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS;
-
-/**
- * The real-world capability state: every primary backend is available (once
- * its own runtime preflight passes), docker and gvisor enclaves are
- * available once their preflight passes, and the sbx enclave backend is
- * always `blocked` — never `unavailable` — because the CLI/daemon exists but
- * cannot prove the mandatory isolation controls (see ./sbx-capability.ts).
- */
-const deterministicCapabilities: BoundedAgentRuntimeCapabilities = {
- primary: {
- docker: 'supported',
- gvisor: 'supported',
- sbx: 'supported',
- },
- enclave: {
- docker: 'supported',
- gvisor: 'supported',
- sbx: 'blocked',
- },
-};
-
-const combinations = PRIMARY_BACKENDS.flatMap((primaryBackend) =>
- BOUNDED_AGENT_BACKENDS.map((boundedAgentBackend) => ({ primaryBackend, boundedAgentBackend })));
-const executableCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) =>
- evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported);
-const blockedCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) =>
- !evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported);
-
-interface HarnessOptions {
- maxInvocations?: number;
- sensitivity?: 'public' | 'internal' | 'confidential';
- output?: string;
- runnerResult?: { exitCode: number; timedOut: boolean };
- processingMs?: number;
-}
-
-async function invoke(
- broker: { handle: (request: unknown, respond: (json: string) => void) => Promise },
- request: unknown,
-): Promise {
- let response = '';
- await broker.handle(request, (json: string) => {
- response = json;
- });
- return response;
-}
-
-function createHarness(
- primaryBackend: BoundedAgentPrimaryBackend,
- boundedAgentBackend: 'docker' | 'gvisor',
- options: HarnessOptions = {},
-) {
- const outputs = new Map();
- const launches: Array> = [];
- const destroyed: string[] = [];
- const telemetry: Array> = [];
- let now = 0;
- const sleeps: number[] = [];
- const config = {
- primaryBackend,
- backend: boundedAgentBackend,
- workDir: '/broker/private/work',
- timeoutSeconds: 30,
- maxInvocations: options.maxInvocations ?? 8,
- maxTaskBytes: 4096,
- };
- const workspace = {
- createInvocationWorkspace: ({
- invocationId,
- task,
- }: {
- invocationId: string;
- task: string;
- }) => {
- expect(task).not.toMatch(/TOKEN|PASSWORD|docker\.sock|broker\/private/);
- return { outPath: invocationId, sessionLogPath: `${invocationId}.jsonl` };
- },
- readEnclaveOutput: (outPath: string) => {
- const output = outputs.get(outPath);
- return output !== undefined && Buffer.byteLength(output) <= 8192 ? output : undefined;
- },
- preserveInvocationSession: () => true,
- destroyInvocationWorkspace: (_workDir: string, invocationId: string) => {
- destroyed.push(invocationId);
- outputs.delete(invocationId);
- },
- };
- const runner = {
- runEnclaveContainer: async (params: Record) => {
- launches.push(params);
- now += options.processingMs ?? 0;
- outputs.set(String(params.invocationId), options.output ?? 'true');
- return {
- exitCode: options.runnerResult?.exitCode ?? 0,
- timedOut: options.runnerResult?.timedOut ?? false,
- };
- },
- };
- const broker = createBroker({
- config,
- seedMap: new Map([
- ['octo/repo', { seedId: 'a'.repeat(32), sensitivity: options.sensitivity ?? 'internal' }],
- ]),
- runId: 'abcd1234',
- audit: { invocation() {}, failure() {}, lifecycle() {} },
- telemetry: { emit: (event: Record) => telemetry.push(event) },
- workspace,
- runner,
- clock: {
- nowMs: () => now,
- sleep: async (ms: number) => {
- sleeps.push(ms);
- now += ms;
- },
- },
- });
- return { broker, destroyed, launches, sleeps, telemetry };
-}
-
-describe('bounded-agent runtime conformance matrix', () => {
- it('contains every independent primary/boundedAgent combination exactly once', () => {
- expect(combinations).toHaveLength(9);
- expect(new Set(combinations.map(({ primaryBackend, boundedAgentBackend }) =>
- `${primaryBackend}/${boundedAgentBackend}`)).size).toBe(9);
- expect(executableCombinations).toHaveLength(6);
- expect(blockedCombinations).toHaveLength(3);
- });
-
- it('supports every primary backend paired with docker/gvisor bounded agents, and blocks sbx bounded agents everywhere', () => {
- const readyPairs = new Set(executableCombinations.map(
- ({ primaryBackend, boundedAgentBackend }) => `${primaryBackend}/${boundedAgentBackend}`,
- ));
- for (const primaryBackend of PRIMARY_BACKENDS) {
- expect(readyPairs.has(`${primaryBackend}/docker`)).toBe(true);
- expect(readyPairs.has(`${primaryBackend}/gvisor`)).toBe(true);
- expect(readyPairs.has(`${primaryBackend}/sbx`)).toBe(false);
- }
- });
-
- it.each(blockedCombinations)(
- '$primaryBackend primary + $boundedAgentBackend bounded agent fails closed at enclave preflight',
- ({ primaryBackend, boundedAgentBackend }) => {
- const result = evaluateBoundedAgentRuntimeCombination(
- primaryBackend,
- boundedAgentBackend,
- deterministicCapabilities,
- );
- expect(result).toEqual({
- primaryBackend,
- boundedAgentBackend,
- supported: false,
- capabilityState: 'blocked',
- blockedAt: 'enclave-preflight',
- category: 'enclave-security-block',
- });
- },
- );
-
- it.each([
- ['gvisor', 'docker', 'primary-preflight', 'primary-runtime-unavailable'],
- ['sbx', 'docker', 'primary-preflight', 'primary-runtime-unavailable'],
- ['docker', 'gvisor', 'enclave-preflight', 'enclave-runtime-unavailable'],
- ] as const)(
- 'reports precise unavailable capability state for %s/%s',
- (primaryBackend, boundedAgentBackend, blockedAt, category) => {
- const capabilities: BoundedAgentRuntimeCapabilities = {
- primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' },
- enclave: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' },
- };
- expect(evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities))
- .toMatchObject({ supported: false, capabilityState: 'unavailable', blockedAt, category });
- },
- );
-
- it('evaluates the full matrix via evaluateBoundedAgentRuntimeMatrix in the same order', () => {
- const matrix = evaluateBoundedAgentRuntimeMatrix(deterministicCapabilities);
- expect(matrix).toHaveLength(9);
- expect(matrix).toEqual(combinations.map(({ primaryBackend, boundedAgentBackend }) =>
- evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities)));
- });
-
- it.each(executableCombinations)(
- '$primaryBackend primary + $boundedAgentBackend bounded agent satisfies the common behavioral contract',
- async ({ primaryBackend, boundedAgentBackend }) => {
- if (boundedAgentBackend === 'sbx') throw new Error('blocked sbx bounded-agent combination entered executable suite');
-
- const successful = createHarness(primaryBackend, boundedAgentBackend, { processingMs: 50 });
- expect(await invoke(successful.broker, {
- privateRepo: 'octo/repo',
- schema: { type: 'boolean' },
- task: 'is this finite?',
- })).toBe('{"status":"ok","result":true}');
- expect(successful.launches).toHaveLength(1);
- expect(successful.destroyed).toHaveLength(1);
- expect(successful.sleeps).toEqual([50]);
- expect(successful.telemetry).toContainEqual({
- primaryBackend,
- boundedAgentBackend,
- lifecycleClass: 'invocation',
- capabilityState: 'supported',
- category: 'success',
- });
- expect(successful.launches[0]).not.toHaveProperty('repo');
- expect(JSON.stringify(successful.launches[0])).not.toMatch(/TOKEN|PASSWORD|docker\.sock/);
-
- const wrongRepo = createHarness(primaryBackend, boundedAgentBackend);
- expect(await invoke(wrongRepo.broker, {
- privateRepo: 'octo/not-configured',
- schema: { type: 'boolean' },
- task: 'must not launch',
- })).toBe(CANONICAL_ERROR);
- expect(wrongRepo.launches).toHaveLength(0);
-
- const capped = createHarness(primaryBackend, boundedAgentBackend, { maxInvocations: 1 });
- const request = { privateRepo: 'octo/repo', schema: { type: 'boolean' }, task: 'cap invocation' };
- expect(await invoke(capped.broker, request)).toBe('{"status":"ok","result":true}');
- expect(await invoke(capped.broker, request)).toBe(CANONICAL_ERROR);
- expect(capped.launches).toHaveLength(1);
-
- for (const failure of [
- { output: '{malformed', runnerResult: undefined },
- { output: 'true', runnerResult: { exitCode: 137, timedOut: true } },
- { output: 'true', runnerResult: { exitCode: 1, timedOut: false } },
- ]) {
- const failed = createHarness(primaryBackend, boundedAgentBackend, failure);
- // eslint-disable-next-line no-await-in-loop
- expect(await invoke(failed.broker, request)).toBe(CANONICAL_ERROR);
- expect(failed.destroyed).toHaveLength(1);
- }
- },
- );
-});
-
-describe('resolveBoundedAgentPrimaryBackend', () => {
- it.each([
- [undefined, 'docker'],
- ['docker', 'docker'],
- ['gvisor', 'gvisor'],
- ['runsc', 'gvisor'],
- ['sbx', 'sbx'],
- ['kata', 'docker'],
- ] as const)('maps containerRuntime %s to primary backend %s', (containerRuntime, expected) => {
- expect(resolveBoundedAgentPrimaryBackend(containerRuntime)).toBe(expected);
- });
-});
-
-describe('bounded-agent runtime telemetry', () => {
- it('serializes only the five approved fields', () => {
- const serialized = serializeBoundedAgentRuntimeTelemetry({
- primaryBackend: resolveBoundedAgentPrimaryBackend('runsc'),
- boundedAgentBackend: 'docker' as BoundedAgentRuntime,
- lifecycleClass: 'preflight',
- capabilityState: 'supported',
- category: 'ready',
- });
- expect(JSON.parse(serialized)).toEqual({
- primaryBackend: 'gvisor',
- boundedAgentBackend: 'docker',
- lifecycleClass: 'preflight',
- capabilityState: 'supported',
- category: 'ready',
- });
- });
-
- it('persists exact-field records without content, paths, outputs, or credentials', () => {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-'));
- try {
- const telemetry = createRuntimeTelemetry(root);
- telemetry.emit({
- primaryBackend: 'sbx',
- boundedAgentBackend: 'docker',
- lifecycleClass: 'invocation',
- capabilityState: 'supported',
- category: 'timeout',
- repo: 'must-be-ignored',
- task: 'must-be-ignored',
- output: 'must-be-ignored',
- path: '/must-be-ignored',
- token: 'must-be-ignored',
- capability: 'must-be-ignored',
- });
- const record = JSON.parse(fs.readFileSync(path.join(root, 'runtime-telemetry.jsonl'), 'utf8'));
- expect(Object.keys(record)).toEqual([
- 'primaryBackend',
- 'boundedAgentBackend',
- 'lifecycleClass',
- 'capabilityState',
- 'category',
- ]);
- expect(JSON.stringify(record)).not.toContain('must-be-ignored');
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
- });
-
- it('rejects an enclave-runtime value outside the fixed enum', () => {
- const brokerRuntimeTelemetry = createRuntimeTelemetry(
- fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-invalid-')),
- );
- expect(() => brokerRuntimeTelemetry.emit({
- primaryBackend: 'docker',
- boundedAgentBackend: 'firecracker',
- lifecycleClass: 'invocation',
- capabilityState: 'supported',
- category: 'success',
- })).toThrow(/Invalid bounded-agent telemetry/);
- });
-});
diff --git a/src/bounded-agent/runtime-matrix.ts b/src/bounded-agent/runtime-matrix.ts
deleted file mode 100644
index 59db0b879..000000000
--- a/src/bounded-agent/runtime-matrix.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import type { BoundedAgentRuntime } from '../types';
-
-export const BOUNDED_AGENT_RUNTIME_BACKENDS = ['docker', 'gvisor', 'sbx'] as const;
-
-export type BoundedAgentPrimaryBackend = (typeof BOUNDED_AGENT_RUNTIME_BACKENDS)[number];
-export type BoundedAgentCapabilityState = 'supported' | 'unavailable' | 'blocked';
-
-export interface BoundedAgentRuntimeCapabilities {
- primary: Readonly>;
- enclave: Readonly>;
-}
-
-export interface BoundedAgentRuntimeCombination {
- primaryBackend: BoundedAgentPrimaryBackend;
- boundedAgentBackend: BoundedAgentRuntime;
- supported: boolean;
- capabilityState: BoundedAgentCapabilityState;
- blockedAt?: 'primary-preflight' | 'enclave-preflight';
- category: 'ready' | 'primary-runtime-unavailable' | 'enclave-runtime-unavailable' | 'enclave-security-block';
-}
-
-export interface BoundedAgentRuntimeTelemetry {
- primaryBackend: BoundedAgentPrimaryBackend;
- boundedAgentBackend: BoundedAgentRuntime;
- lifecycleClass: 'preflight' | 'startup' | 'invocation' | 'cleanup';
- capabilityState: BoundedAgentCapabilityState;
- category: string;
-}
-
-/** Maps AWF's execution setting to the independent primary-agent matrix axis. */
-export function resolveBoundedAgentPrimaryBackend(
- containerRuntime: string | undefined,
-): BoundedAgentPrimaryBackend {
- if (containerRuntime === 'gvisor' || containerRuntime === 'runsc') return 'gvisor';
- if (containerRuntime === 'sbx') return 'sbx';
- return 'docker';
-}
-
-/**
- * Evaluates one primary/enclave pair without fallback.
- *
- * Primary availability is checked first because the primary agent cannot be
- * started without it. Enclave availability is then checked before any
- * repository staging. A blocked enclave capability is distinct from an
- * unavailable binary: it means the runtime exists but cannot enforce AWF's
- * mandatory isolation and API-proxy-only network controls.
- *
- * All nine (primary x boundedAgent) combinations are evaluated independently:
- * a supported primary backend never implies a supported enclave backend, and
- * vice versa.
- */
-export function evaluateBoundedAgentRuntimeCombination(
- primaryBackend: BoundedAgentPrimaryBackend,
- boundedAgentBackend: BoundedAgentRuntime,
- capabilities: BoundedAgentRuntimeCapabilities,
-): BoundedAgentRuntimeCombination {
- const primaryState = capabilities.primary[primaryBackend];
- if (primaryState !== 'supported') {
- return {
- primaryBackend,
- boundedAgentBackend,
- supported: false,
- capabilityState: primaryState,
- blockedAt: 'primary-preflight',
- category: 'primary-runtime-unavailable',
- };
- }
-
- const enclaveState = capabilities.enclave[boundedAgentBackend];
- if (enclaveState !== 'supported') {
- return {
- primaryBackend,
- boundedAgentBackend,
- supported: false,
- capabilityState: enclaveState,
- blockedAt: 'enclave-preflight',
- category: enclaveState === 'blocked' ? 'enclave-security-block' : 'enclave-runtime-unavailable',
- };
- }
-
- return {
- primaryBackend,
- boundedAgentBackend,
- supported: true,
- capabilityState: 'supported',
- category: 'ready',
- };
-}
-
-/** Evaluates every (primary x boundedAgent) combination independently. */
-export function evaluateBoundedAgentRuntimeMatrix(
- capabilities: BoundedAgentRuntimeCapabilities,
-): BoundedAgentRuntimeCombination[] {
- const combinations: BoundedAgentRuntimeCombination[] = [];
- for (const primaryBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) {
- for (const boundedAgentBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) {
- combinations.push(
- evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities),
- );
- }
- }
- return combinations;
-}
-
-/** Serializes the intentionally narrow, path- and content-free telemetry shape. */
-export function serializeBoundedAgentRuntimeTelemetry(
- event: BoundedAgentRuntimeTelemetry,
-): string {
- return JSON.stringify({
- primaryBackend: event.primaryBackend,
- boundedAgentBackend: event.boundedAgentBackend,
- lifecycleClass: event.lifecycleClass,
- capabilityState: event.capabilityState,
- category: event.category,
- });
-}
diff --git a/src/bounded-agent/sbx-capability.test.ts b/src/bounded-agent/sbx-capability.test.ts
deleted file mode 100644
index 26d51f041..000000000
--- a/src/bounded-agent/sbx-capability.test.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/* eslint-disable @typescript-eslint/no-require-imports -- container-side broker
- module is loaded at runtime for a byte-for-byte cross-check; it is a plain
- .js file with no TS types, so `require()` is the correct (and only) way to
- pull it in, matching the pattern used by src/bounded-query/*.test.ts. */
-import execa from 'execa';
-import path from 'path';
-import {
- boundedAgentSbxCapabilityTestHelpers as helpers,
- defaultBoundedAgentSbxCapabilityQuery,
-} from './sbx-capability';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-const mockExeca = execa as unknown as jest.Mock;
-
-/**
- * Host-side capability probe coverage for the bounded-agent sbx enclave
- * backend.
- *
- * This backend has a strictly harder network requirement than bounded
- * queries: an enclave must reach exactly one peer (the API proxy), not
- * "no network at all". So `missing` always includes the pinned-template and
- * lateral-peer-denial entries regardless of what flags are detected — the
- * probe can never report `supported: true` for the currently audited sbx
- * 0.37.1 CLI, by design.
- */
-describe('defaultBoundedAgentSbxCapabilityQuery', () => {
- beforeEach(() => {
- mockExeca.mockReset();
- });
-
- it('never reports supported even when every flag is present, because the network primitive is unverifiable', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) // version
- .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) // ls (daemon reachability)
- .mockResolvedValueOnce({
- exitCode: 0,
- stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target',
- }) // create --help
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); // exec --help
-
- const report = await defaultBoundedAgentSbxCapabilityQuery();
-
- expect(report.supported).toBe(false);
- expect(report.version).toBe('0.37.1');
- expect(report.auditedVersion).toBe('0.37.1');
- expect(report.missing).toContain('pinned AWF bounded-agent sbx template and bootstrap');
- expect(report.missing).toContain(
- 'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' +
- '(hard network-policy / capability-token ingress primitive)',
- );
- // Every enumerated flag was detected, so nothing else should be missing.
- expect(report.missing).not.toContain('sbx create --network');
- expect(report.missing).not.toContain('authenticated sbx CLI/daemon');
- });
-
- it('reports every missing lifecycle/resource flag when help output lacks them', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' });
-
- const report = await defaultBoundedAgentSbxCapabilityQuery();
-
- expect(report.supported).toBe(false);
- expect(report.missing).toEqual(expect.arrayContaining([
- 'pinned AWF bounded-agent sbx template and bootstrap',
- 'sbx create --pids-limit',
- 'sbx create --disk-limit',
- 'sbx create --ulimit-fsize',
- 'sbx create --mount-target',
- ]));
- });
-
- it('reports an unsupported audited version distinctly from missing flags', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.99.0' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' })
- .mockResolvedValueOnce({
- exitCode: 0,
- stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target',
- })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' });
-
- const report = await defaultBoundedAgentSbxCapabilityQuery();
- expect(report.missing).toContain('audited sbx version 0.37.1 (found 0.99.0)');
- });
-
- it('reports an unauthenticated or unreachable daemon', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' })
- .mockResolvedValueOnce({ exitCode: 1, stdout: '' }) // ls fails: daemon unreachable/unauthenticated
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' });
-
- const report = await defaultBoundedAgentSbxCapabilityQuery();
- expect(report.missing).toContain('authenticated sbx CLI/daemon');
- });
-
- it('fails closed when the sbx binary is entirely absent', async () => {
- mockExeca.mockRejectedValue(new Error('spawn sbx ENOENT'));
- const report = await defaultBoundedAgentSbxCapabilityQuery();
- expect(report).toEqual({
- supported: false,
- auditedVersion: '0.37.1',
- missing: ['authenticated sbx CLI/daemon'],
- });
- });
-
- it('never uses request-scoped or credential-bearing environment beyond the process env', async () => {
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' });
- await defaultBoundedAgentSbxCapabilityQuery();
- for (const call of mockExeca.mock.calls) {
- const options = call[2] as { env?: Record } | undefined;
- expect(options?.env).not.toHaveProperty('DOCKER_SANDBOXES_PROXY');
- expect(options?.env).not.toHaveProperty('XDG_CONFIG_HOME');
- }
- });
-
- it('keeps the required-flag lists byte-for-byte aligned with the container-side probe', () => {
- const containerProbe = require(path.join(
- __dirname,
- '..',
- '..',
- 'containers',
- 'bounded-agent',
- 'broker',
- 'sbx-capability-probe.js',
- ));
- expect(helpers.SBX_AUDITED_VERSION).toBe(containerProbe.AUDITED_SBX_VERSION);
- // The host-side probe collapses REQUIRED_CREATE_FLAGS and
- // REQUIRED_HARD_ISOLATION_FLAGS into one list (it stops before staging
- // rather than launching, so it has no reason to distinguish lifecycle
- // flags from hard-isolation flags), except `--network`: host-side never
- // treats its presence as informative, because the unconditional
- // lateral-peer-denial entry already reports the network requirement
- // missing regardless of flag detection — checking the flag too would
- // only invite a false sense of partial progress.
- const containerHardIsolationWithoutNetwork = containerProbe.REQUIRED_HARD_ISOLATION_FLAGS
- .filter((flag: string) => flag !== '--network');
- expect(new Set(helpers.SBX_REQUIRED_CREATE_FLAGS)).toEqual(new Set([
- ...containerProbe.REQUIRED_CREATE_FLAGS,
- ...containerHardIsolationWithoutNetwork,
- ]));
- expect(helpers.SBX_REQUIRED_EXEC_FLAGS).toEqual(containerProbe.REQUIRED_EXEC_FLAGS);
- });
-});
-
-describe('helpIncludesFlag', () => {
- it('matches a flag as a standalone token, not a substring of another flag', () => {
- expect(helpers.helpIncludesFlag('--network, --network-mode', '--network')).toBe(true);
- expect(helpers.helpIncludesFlag('--network-mode', '--network')).toBe(false);
- expect(helpers.helpIncludesFlag(' --cpus= Number of vCPUs', '--cpus')).toBe(true);
- expect(helpers.helpIncludesFlag('no matching flags here', '--cpus')).toBe(false);
- });
-});
diff --git a/src/bounded-agent/sbx-capability.ts b/src/bounded-agent/sbx-capability.ts
deleted file mode 100644
index 780fab629..000000000
--- a/src/bounded-agent/sbx-capability.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import execa from 'execa';
-
-/**
- * Host-side capability probe for the bounded-agent `sbx` enclave runtime.
- *
- * This is deliberately its own module (not a re-export of the bounded-query
- * probe) because bounded agents have a strictly harder requirement: a bounded
- * *query* sandbox needs `--network=none` (no egress at all), while a bounded
- * *agent* enclave must reach exactly one peer — the dedicated, API-proxy-only
- * enclave network — and nothing else. sbx has no primitive that can attach a
- * sandbox to a named Docker network while also enforcing that no other peer
- * on that network (or the internet) is reachable, so that requirement is
- * always reported missing below rather than inferred from a flag that would
- * only prove the weaker no-network case.
- */
-
-const SBX_AUDITED_VERSION = '0.37.1';
-
-/** Flags proven by `sbx create --help` inspection. */
-const SBX_REQUIRED_CREATE_FLAGS = [
- '--cpus',
- '--memory',
- '--name',
- '--template',
- '--pids-limit',
- '--disk-limit',
- '--ulimit-fsize',
- '--mount-target',
-] as const;
-
-/** Flags proven by `sbx exec --help` inspection. */
-const SBX_REQUIRED_EXEC_FLAGS = ['--user', '--workdir'] as const;
-
-export interface BoundedAgentSbxCapabilityReport {
- supported: boolean;
- version?: string;
- auditedVersion: string;
- missing: string[];
-}
-
-/** Executes the minimum host-side capability proof for the sbx enclave backend. */
-export type BoundedAgentSbxCapabilityQuery = () => Promise;
-
-function helpIncludesFlag(help: string, flag: string): boolean {
- const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help);
-}
-
-/**
- * Probes the installed `sbx` CLI for every capability the bounded-agent
- * enclave requires: lifecycle (create/exec/stop/rm), read-only targeted
- * mounts, unprivileged exec identity/workdir, resource and storage limits,
- * and — the category current sbx cannot satisfy — a hard, API-proxy-only
- * network-isolation primitive with mandatory lateral-peer denial.
- *
- * Help/version output alone never marks the runtime supported: every
- * unconditional architectural gap below is always reported so a future sbx
- * release cannot be silently treated as capable of this feature by CLI-flag
- * drift alone.
- */
-export const defaultBoundedAgentSbxCapabilityQuery: BoundedAgentSbxCapabilityQuery = async () => {
- const managementEnv = { ...process.env };
- delete managementEnv.DOCKER_SANDBOXES_PROXY;
- delete managementEnv.XDG_CONFIG_HOME;
-
- const run = async (args: string[]): Promise<{ exitCode: number; stdout: string }> => {
- const result = await execa('sbx', args, {
- reject: false,
- timeout: 10_000,
- env: managementEnv,
- });
- return { exitCode: result.exitCode ?? 1, stdout: result.stdout };
- };
-
- let versionResult: { exitCode: number; stdout: string };
- let daemonResult: { exitCode: number; stdout: string };
- let createHelp: { exitCode: number; stdout: string };
- let execHelp: { exitCode: number; stdout: string };
- try {
- [versionResult, daemonResult, createHelp, execHelp] = await Promise.all([
- run(['version']),
- // sbx has no auth-status command; listing is authenticated and non-mutating.
- run(['ls']),
- run(['create', '--help']),
- run(['exec', '--help']),
- ]);
- } catch {
- return {
- supported: false,
- auditedVersion: SBX_AUDITED_VERSION,
- missing: ['authenticated sbx CLI/daemon'],
- };
- }
-
- const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout)?.[1];
- const missing: string[] = [
- // AWF has not published the immutable, AWF-authored enclave template and
- // bootstrap for sbx because current sbx cannot yet enforce the network
- // primitive below — publishing one would imply a false capability claim.
- 'pinned AWF bounded-agent sbx template and bootstrap',
- // sbx v0.37.1 has no primitive that attaches a sandbox to a named network
- // while denying every peer except one configured endpoint. Local
- // HTTP_PROXY / org-level network policy is advisory, not a hard control,
- // and organization governance can replace it — so it never counts here.
- 'sbx named-network attach with mandatory lateral-peer denial to enforce ' +
- 'API-proxy-only egress (hard network-policy / capability-token ingress primitive)',
- ];
- if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) {
- missing.push('authenticated sbx CLI/daemon');
- }
- if (version && version !== SBX_AUDITED_VERSION) {
- missing.push(`audited sbx version ${SBX_AUDITED_VERSION} (found ${version})`);
- }
- for (const flag of SBX_REQUIRED_CREATE_FLAGS) {
- if (createHelp.exitCode !== 0 || !helpIncludesFlag(createHelp.stdout, flag)) {
- missing.push(`sbx create ${flag}`);
- }
- }
- for (const flag of SBX_REQUIRED_EXEC_FLAGS) {
- if (execHelp.exitCode !== 0 || !helpIncludesFlag(execHelp.stdout, flag)) {
- missing.push(`sbx exec ${flag}`);
- }
- }
- return { supported: missing.length === 0, version, auditedVersion: SBX_AUDITED_VERSION, missing };
-};
-
-/** @internal Exported for focused unit tests. */
-// ts-prune-ignore-next
-export const boundedAgentSbxCapabilityTestHelpers = {
- SBX_AUDITED_VERSION,
- SBX_REQUIRED_CREATE_FLAGS,
- SBX_REQUIRED_EXEC_FLAGS,
- helpIncludesFlag,
-};
diff --git a/src/bounded-agent/sbx-enclave-runner.test.ts b/src/bounded-agent/sbx-enclave-runner.test.ts
deleted file mode 100644
index 50bc5f6c9..000000000
--- a/src/bounded-agent/sbx-enclave-runner.test.ts
+++ /dev/null
@@ -1,457 +0,0 @@
-import * as path from 'path';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js'));
-const {
- deriveSbxEnclaveSpec,
- SBX_ENCLAVE_TEMPLATE,
- REQUIRED_HARD_ISOLATION_FLAGS,
-} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-/**
- * Security-critical contract tests for the bounded-agent sbx enclave runner.
- *
- * This mirrors the coverage bounded queries already have for their sbx
- * backend (`src/bounded-query/query-runner.test.ts:158-305`): a fixed launch
- * specification derived only from trusted identifiers, capability rejection,
- * trusted-ID validation, create/exec timeout accounting, prefix-scoped
- * reconciliation, malformed-inventory rejection, and guaranteed stop/remove
- * cleanup — including when cleanup itself fails.
- */
-
-interface SbxResult {
- exitCode: number;
- timedOut: boolean;
- stdout: string;
- stderr: string;
-}
-
-const ok = (overrides: Partial = {}): SbxResult => ({
- exitCode: 0,
- timedOut: false,
- stdout: '',
- stderr: '',
- ...overrides,
-});
-
-const config = {
- sbxWorkDir: '/sbx-daemon/private/work',
- sbxSeedsDir: '/sbx-daemon/private/seeds',
- enclaveSeedPath: '/awf/seed',
- enclaveTaskPath: '/awf/task.txt',
- enclaveSchemaPath: '/awf/schema.json',
- enclaveMountDir: '/agent',
- enclaveUid: 65534,
- enclaveGid: 65534,
- cpuLimit: '1',
- memoryLimit: '512m',
- network: 'awf-bounded-agent',
- pidsLimit: 128,
- tmpfsLimit: '64m',
- timeoutSeconds: 120,
-};
-
-const RUN_ID = 'abcd1234abcd1234abcd1234abcd1234';
-const INVOCATION_ID = '111111111111111111111111';
-const SEED_ID = 'a'.repeat(32);
-
-type SbxHandler = (args: readonly string[], timeoutMs: number) => SbxResult | Promise;
-
-function createSbx(handler: SbxHandler = () => ok()) {
- const calls: string[][] = [];
- const timeouts: number[] = [];
- return {
- calls,
- timeouts,
- client: {
- runSbx: async (args: readonly string[], timeoutMs: number) => {
- calls.push([...args]);
- timeouts.push(timeoutMs);
- return handler(args, timeoutMs);
- },
- },
- };
-}
-
-function createFiles() {
- const created: string[] = [];
- return {
- created,
- files: {
- mkdirSync: (target: string) => {
- created.push(target);
- },
- },
- };
-}
-
-const availableProbe = async () => ({ supported: true, missing: [] });
-
-describe('bounded-agent sbx enclave runner contract', () => {
- describe('deriveSbxEnclaveSpec: fixed spec derived only from trusted identifiers', () => {
- it('derives a frozen, unique-per-invocation launch specification', () => {
- const first = deriveSbxEnclaveSpec({
- config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID,
- });
- const second = deriveSbxEnclaveSpec({
- config, runId: RUN_ID, invocationId: '222222222222222222222222', seedId: SEED_ID,
- });
-
- expect(Object.isFrozen(first)).toBe(true);
- expect(Object.isFrozen(first.createArgs)).toBe(true);
- expect(Object.isFrozen(first.execArgs)).toBe(true);
- expect(first.sandboxName).not.toBe(second.sandboxName);
- expect(first.runPrefix).toBe(`awf-bounded-agent-sbx-${RUN_ID}-`);
- expect(first.sandboxName).toBe(`${first.runPrefix}${INVOCATION_ID}`);
- expect(first.createArgs).toContain(SBX_ENCLAVE_TEMPLATE);
- for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) {
- expect(first.createArgs).toContain(flag);
- }
- expect(first.createArgs.join(' ')).toContain(
- `${config.sbxSeedsDir}/${SEED_ID}:${config.enclaveSeedPath}:ro`,
- );
- expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/task.txt`);
- expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/schema.json`);
- expect(first.execArgs).toContain(`${config.enclaveUid}:${config.enclaveGid}`);
- expect(first.execArgs).toContain(config.enclaveMountDir);
- expect(first.execArgs).toContain(first.sandboxName);
- expect(first.execArgs.slice(-1)).toEqual(['/usr/local/bin/run-bounded-agent']);
- expect(first.stopArgs).toEqual(['stop', first.sandboxName]);
- expect(first.removeArgs).toEqual(['rm', '--force', first.sandboxName]);
- expect(first.listArgs).toEqual(['ls', '--json']);
- });
-
- it.each([
- ['runId', { runId: 'not-hex', invocationId: INVOCATION_ID, seedId: SEED_ID }, /runId/],
- ['invocationId', { runId: RUN_ID, invocationId: 'short', seedId: SEED_ID }, /invocationId/],
- ['seedId', { runId: RUN_ID, invocationId: INVOCATION_ID, seedId: 'zz' }, /seedId/],
- ['runId with injection', {
- runId: `${RUN_ID}; rm -rf /`, invocationId: INVOCATION_ID, seedId: SEED_ID,
- }, /runId/],
- ])('rejects a malformed or untrusted %s', (_name, params, message) => {
- expect(() => deriveSbxEnclaveSpec({ config, ...params })).toThrow(message);
- });
- });
-
- describe('assertAvailable: capability rejection', () => {
- it('blocks the audited sbx CLI and reports every missing capability', async () => {
- const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network'];
- const runner = new SbxEnclaveRunner(config, {
- probe: async () => ({ supported: false, missing }),
- });
-
- await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s);
- await expect(runner.assertAvailable()).rejects.toThrow(
- 'pinned AWF bounded-agent sbx template and bootstrap',
- );
- await expect(runner.assertAvailable()).rejects.toThrow('sbx create --network');
- });
-
- it('never launches when the probe throws instead of returning a report', async () => {
- const runner = new SbxEnclaveRunner(config, {
- probe: async () => {
- throw new Error('sbx CLI not found');
- },
- });
- await expect(runner.assertAvailable()).rejects.toThrow('sbx CLI not found');
- });
- });
-
- describe('runEnclaveContainer: create/exec timeout accounting', () => {
- it('runs exec with the remaining budget after a successful create', async () => {
- let now = 0;
- const { calls, timeouts, client } = createSbx((args) => {
- if (args[0] === 'create') {
- now += 10_000; // simulate elapsed wall-clock time during create
- }
- return ok();
- });
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, {
- sbx: client,
- probe: availableProbe,
- files,
- nowMs: () => now,
- });
-
- const result = await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- timeoutMs: 60_000,
- });
-
- expect(result).toEqual({ exitCode: 0, timedOut: false });
- const createIndex = calls.findIndex((call) => call[0] === 'create');
- const execIndex = calls.findIndex((call) => call[0] === 'exec');
- expect(createIndex).toBeGreaterThanOrEqual(0);
- expect(execIndex).toBeGreaterThan(createIndex);
- const orderedTimeouts = [...timeouts];
- const [createTimeoutMs, execTimeoutMs] = createIndex < execIndex
- ? [orderedTimeouts[createIndex], orderedTimeouts[execIndex]]
- : [orderedTimeouts[execIndex], orderedTimeouts[createIndex]];
- // create is capped at 120s even though the full budget (60s + grace) is larger.
- expect(createTimeoutMs).toBeLessThanOrEqual(120_000);
- // exec receives the budget remaining after create's simulated 10s elapsed.
- expect(execTimeoutMs).toBeLessThanOrEqual(60_000 + 15_000);
- expect(execTimeoutMs).toBeLessThan(createTimeoutMs);
- });
-
- it('returns a timed-out result and never execs when create itself times out', async () => {
- const { calls, client } = createSbx((args) => (
- args[0] === 'create' ? ok({ timedOut: true, exitCode: 124 }) : ok()
- ));
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- const result = await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- timeoutMs: 1_000,
- });
-
- expect(result).toEqual({ exitCode: 124, timedOut: true });
- expect(calls.some((call) => call[0] === 'exec')).toBe(false);
- // Cleanup still runs deterministically after a create timeout.
- expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]);
- expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]);
- });
-
- it('skips exec and reports a synthetic timeout when the deadline elapses between create and exec', async () => {
- let now = 0;
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'create') {
- now += 1_000_000; // blow through the deadline entirely during create
- }
- return ok();
- });
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, {
- sbx: client, probe: availableProbe, files, nowMs: () => now,
- });
-
- const result = await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- timeoutMs: 1_000,
- });
-
- expect(result).toEqual({ exitCode: 124, timedOut: true });
- expect(calls.some((call) => call[0] === 'exec')).toBe(false);
- });
-
- it('throws and still cleans up when create fails outright', async () => {
- const { calls, client } = createSbx((args) => (
- args[0] === 'create' ? ok({ exitCode: 1 }) : ok()
- ));
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).rejects.toThrow('Failed to create bounded-agent sbx VM');
- expect(calls.some((call) => call[0] === 'exec')).toBe(false);
- expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]);
- expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]);
- });
- });
-
- describe('deterministic stop/rm cleanup, including cleanup failures', () => {
- it('always force-removes the uniquely named VM before returning a success', async () => {
- const { calls, client } = createSbx((args) => (
- args[0] === 'ls' && args[1] === '--quiet' ? ok({ stdout: '' }) : ok()
- ));
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).resolves.toEqual({ exitCode: 0, timedOut: false });
-
- const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`;
- expect(calls.find((args) => args[0] === 'create')).toContain(name);
- expect(calls.find((args) => args[0] === 'exec')).toContain(name);
- expect(calls).toContainEqual(['stop', name]);
- expect(calls).toContainEqual(['rm', '--force', name]);
- expect(calls[calls.length - 1]).toEqual(['rm', '--force', name]);
- });
-
- it('preserves a successful result when stop fails but inventory confirms the VM is already gone', async () => {
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'stop') return ok({ exitCode: 1 });
- if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: '' });
- return ok();
- });
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).resolves.toEqual({ exitCode: 0, timedOut: false });
- const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`;
- expect(calls).toContainEqual(['rm', '--force', name]);
- });
-
- it('fails closed when stop fails and inventory still lists the VM', async () => {
- const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`;
- const { client } = createSbx((args) => {
- if (args[0] === 'stop') return ok({ exitCode: 1 });
- if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: `${name}\n` });
- return ok();
- });
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).rejects.toThrow('Failed to stop bounded-agent sbx VM');
- });
-
- it('fails closed when remove fails after a successful stop', async () => {
- const { client } = createSbx((args) => (args[0] === 'rm' ? ok({ exitCode: 1 }) : ok()));
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).rejects.toThrow('Failed to remove bounded-agent sbx VM');
- });
-
- it('surfaces the cleanup failure even when the run itself also failed (cleanup takes priority)', async () => {
- const { client } = createSbx((args) => {
- if (args[0] === 'create') return ok({ exitCode: 1 });
- if (args[0] === 'rm') return ok({ exitCode: 1 });
- return ok();
- });
- const { files } = createFiles();
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files });
-
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).rejects.toThrow('Failed to remove bounded-agent sbx VM');
- });
-
- it('serializes interruption reconciliation with per-invocation cleanup', async () => {
- const events: string[] = [];
- let releaseStop: (() => void) | undefined;
- const stopGate = new Promise((resolve) => {
- releaseStop = resolve;
- });
- let stopCount = 0;
- const { client } = createSbx(async (args) => {
- if (args[0] === 'stop') {
- stopCount += 1;
- const label = `stop-${stopCount}`;
- events.push(`${label}-start`);
- if (stopCount === 1) await stopGate;
- events.push(`${label}-end`);
- return ok();
- }
- if (args[0] === 'ls' && args[1] === '--json') {
- events.push('reconcile-list');
- return ok({ stdout: '[]' });
- }
- return ok();
- });
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe });
-
- const invocationCleanup = runner.cleanupInvocation(RUN_ID, INVOCATION_ID);
- const reconciliation = runner.reconcileRun(RUN_ID);
- await Promise.resolve();
- await Promise.resolve();
- expect(events).toEqual(['stop-1-start']);
- releaseStop?.();
- await Promise.all([invocationCleanup, reconciliation]);
- expect(events).toEqual(['stop-1-start', 'stop-1-end', 'reconcile-list']);
- });
- });
-
- describe('reconcileRun: prefix-scoped reconciliation', () => {
- it('reconciles only sbx VMs with the current trusted run prefix', async () => {
- const staleName = `awf-bounded-agent-sbx-${RUN_ID}-222222222222222222222222`;
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'ls' && args[1] === '--json') {
- return ok({
- stdout: JSON.stringify([
- { name: staleName },
- { name: 'awf-bounded-agent-sbx-other-run-333333333333333333333333' },
- { name: 'awf-query-sbx-primary' },
- ]),
- });
- }
- return ok();
- });
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe });
-
- await runner.reconcileRun(RUN_ID);
-
- expect(calls).toContainEqual(['stop', staleName]);
- expect(calls).toContainEqual(['rm', '--force', staleName]);
- expect(calls.join(' ')).not.toContain('other-run');
- expect(calls.join(' ')).not.toContain('awf-query-sbx-primary');
- });
-
- it('removes nothing when no VM in inventory matches this run prefix', async () => {
- const { calls, client } = createSbx((args) => (
- args[0] === 'ls' && args[1] === '--json'
- ? ok({ stdout: JSON.stringify([{ name: 'awf-bounded-agent-sbx-unrelated-000000000000000000000000' }]) })
- : ok()
- ));
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe });
-
- await runner.reconcileRun(RUN_ID);
-
- expect(calls.some((call) => call[0] === 'stop' || call[0] === 'rm')).toBe(false);
- });
-
- it('fails closed when listing sandboxes itself fails', async () => {
- const { client } = createSbx((args) => (
- args[0] === 'ls' && args[1] === '--json' ? ok({ exitCode: 1 }) : ok()
- ));
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe });
-
- await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow('Failed to reconcile bounded-agent sbx VMs');
- });
- });
-
- describe('parseSandboxNames: malformed inventory rejection', () => {
- it('rejects non-JSON, non-array, and shell-metacharacter-bearing inventory', () => {
- expect(() => parseSandboxNames('not json')).toThrow(/malformed sandbox inventory/);
- expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/);
- expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/);
- expect(() => parseSandboxNames('[{"name":"; rm -rf /"}]')).toThrow(/invalid sandbox name/);
- expect(() => parseSandboxNames('[{}]')).toThrow(/invalid sandbox name/);
- });
-
- it('accepts a well-formed sandbox name list', () => {
- expect(parseSandboxNames('[{"name":"awf-bounded-agent-sbx-abc-123"}]')).toEqual([
- 'awf-bounded-agent-sbx-abc-123',
- ]);
- });
-
- it('rejects malformed sbx inventory rather than accepting cleanup injection', async () => {
- const { client } = createSbx((args) => (
- args[0] === 'ls' ? ok({ stdout: '[{"name":"--all"}]' }) : ok()
- ));
- const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe });
-
- await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow(/invalid sandbox name/);
- });
- });
-});
diff --git a/src/bounded-agent/sbx-runner.test.ts b/src/bounded-agent/sbx-runner.test.ts
deleted file mode 100644
index cfa20338f..000000000
--- a/src/bounded-agent/sbx-runner.test.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import * as path from 'path';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js'));
-const {
- SBX_ENCLAVE_TEMPLATE,
- REQUIRED_HARD_ISOLATION_FLAGS,
- deriveSbxEnclaveSpec,
-} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const RUN_ID = 'a'.repeat(32);
-const INVOCATION_ID = 'b'.repeat(24);
-const SEED_ID = 'c'.repeat(32);
-const config = {
- sbxWorkDir: '/sbx-daemon/private/work',
- sbxSeedsDir: '/sbx-daemon/private/seeds',
- enclaveMountDir: '/agent',
- enclaveSeedPath: '/awf/seed',
- enclaveTaskPath: '/awf/task.txt',
- enclaveSchemaPath: '/awf/schema.json',
- enclaveUid: 65534,
- enclaveGid: 65534,
- network: 'awf-bounded-agent',
- timeoutSeconds: 120,
- memoryLimit: '512m',
- tmpfsLimit: '64m',
- cpuLimit: '1',
- pidsLimit: 128,
-};
-
-const result = (overrides: Record = {}) => ({
- exitCode: 0,
- stdout: '',
- stderr: '',
- timedOut: false,
- ...overrides,
-});
-
-function createSbx(handler: (args: string[], timeout: number) => Record = () => result()) {
- const calls: Array<{ args: string[]; timeout: number }> = [];
- return {
- calls,
- client: {
- runSbx: async (args: string[], timeout: number) => {
- calls.push({ args, timeout });
- return handler(args, timeout);
- },
- },
- };
-}
-
-describe('bounded-agent sbx enclave runner contract', () => {
- it('derives a frozen launch surface only from trusted identifiers', () => {
- const spec = deriveSbxEnclaveSpec({ config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID });
- expect(Object.isFrozen(spec)).toBe(true);
- expect(Object.isFrozen(spec.createArgs)).toBe(true);
- expect(spec.createArgs).toContain(SBX_ENCLAVE_TEMPLATE);
- for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) expect(spec.createArgs).toContain(flag);
- expect(spec.createArgs.join(' ')).toContain(
- `/sbx-daemon/private/seeds/${SEED_ID}:/awf/seed:ro`,
- );
- expect(spec.execArgs).toEqual([
- 'exec', '--user', '65534:65534', '--workdir', '/agent',
- spec.sandboxName, '/usr/local/bin/run-bounded-agent',
- ]);
- });
-
- it('rejects untrusted identifiers before constructing CLI arguments', () => {
- for (const value of ['', '../escape', '--all', 'UPPER']) {
- expect(() => deriveSbxEnclaveSpec({
- config,
- runId: RUN_ID,
- invocationId: value,
- seedId: SEED_ID,
- })).toThrow(/broker-generated identifier/);
- }
- });
-
- it('blocks launch unless every executable capability is proven', async () => {
- const runner = new SbxEnclaveRunner(config, {
- probe: async () => ({ supported: false, missing: ['mandatory network policy'] }),
- });
- await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s);
- });
-
- it('always stops and force-removes the invocation while discarding streams', async () => {
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'exec') return result({ stdout: 'SECRET', stderr: 'DIAGNOSTIC' });
- if (args[0] === 'ls' && args[1] === '--quiet') return result();
- return result();
- });
- const runner = new SbxEnclaveRunner(config, {
- sbx: client,
- probe: async () => ({ supported: true, missing: [] }),
- files: { mkdirSync: jest.fn() },
- });
- await runner.assertAvailable();
- const runResult = await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- timeoutMs: 1000,
- });
- expect(runResult).toEqual({ exitCode: 0, timedOut: false });
- const name = runner.spec(RUN_ID, INVOCATION_ID, SEED_ID).sandboxName;
- expect(calls.map((call) => call.args)).toContainEqual(['stop', name]);
- expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', name]);
- expect(JSON.stringify(runResult)).not.toContain('SECRET');
- });
-
- it('shares one deadline across create and exec', async () => {
- let now = 1000;
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'create') now += 400;
- return result();
- });
- const runner = new SbxEnclaveRunner(config, {
- sbx: client,
- files: { mkdirSync: jest.fn() },
- nowMs: () => now,
- });
- await runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- timeoutMs: 1000,
- });
- const createCall = calls.find((call) => call.args[0] === 'create');
- const execCall = calls.find((call) => call.args[0] === 'exec');
- expect(createCall?.timeout).toBeLessThanOrEqual(16_000);
- expect(execCall?.timeout).toBe(15_600);
- });
-
- it('reconciles only the current trusted run prefix', async () => {
- const stale = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`;
- const { calls, client } = createSbx((args) => (
- args[0] === 'ls' && args[1] === '--json'
- ? result({ stdout: JSON.stringify([{ name: stale }, { name: 'awf-agent-primary' }]) })
- : result()
- ));
- const runner = new SbxEnclaveRunner(config, { sbx: client });
- await runner.reconcileRun(RUN_ID);
- expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', stale]);
- expect(calls.map((call) => call.args).flat()).not.toContain('awf-agent-primary');
- });
-
- it('rejects malformed and option-shaped inventory names', () => {
- expect(() => parseSandboxNames('not-json')).toThrow(/malformed sandbox inventory/);
- expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/);
- expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/);
- });
-
- it('fails closed when cleanup fails after successful execution', async () => {
- const { client } = createSbx((args) => (
- args[0] === 'rm' ? result({ exitCode: 1 }) : result()
- ));
- const runner = new SbxEnclaveRunner(config, {
- sbx: client,
- files: { mkdirSync: jest.fn() },
- });
- await expect(runner.runEnclaveContainer({
- runId: RUN_ID,
- invocationId: INVOCATION_ID,
- seedId: SEED_ID,
- })).rejects.toThrow(/remove bounded-agent sbx VM/);
- });
-});
diff --git a/src/bounded-agent/skill.ts b/src/bounded-agent/skill.ts
deleted file mode 100644
index f96ffe1e3..000000000
--- a/src/bounded-agent/skill.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import * as fs from 'fs';
-import type { BoundedAgentRepository, BoundedAgentsConfig } from '../types/bounded-agent-options';
-import { BOUNDED_AGENT_SENSITIVITY_RUN_BITS } from '../types/bounded-agent-options';
-import { AGENT_SKILL_PATH, type BoundedAgentPaths } from './paths';
-import {
- CANONICAL_ERROR_JSON,
- RESULT_STATUS_BIT_COST,
- TIMING_BUCKETS_MS,
- TIMING_BUCKET_BITS,
-} from './protocol';
-
-/**
- * Generates the bounded-agent skill document handed to the primary agent.
- *
- * The document is *guidance*, not a security boundary: every rule it states is
- * independently enforced by the `bounded-agent` wrapper and by the trusted
- * broker. Its job is to tell the agent which repositories exist (and at what
- * confidentiality budget), the request contract (repository + finite schema +
- * bounded task text), and the observable canonical result envelope.
- */
-
-interface BoundedAgentSkillParams {
- /** Configured repositories, in configuration order. */
- repos: BoundedAgentRepository[];
- /** Per-invocation wall-clock limit, in seconds. */
- timeoutSeconds: number;
- /** Per-run invocation budget (an independent operational cap). */
- maxInvocations: number;
- /** Maximum size of the caller-supplied task text, in bytes. */
- maxTaskBytes: number;
- /** Trusted native coding-agent engine selected for the enclave. */
- engine: BoundedAgentsConfig['engine'];
-}
-
-function formatRunBudget(repo: BoundedAgentRepository): string {
- const bits = BOUNDED_AGENT_SENSITIVITY_RUN_BITS[repo.sensitivity];
- if (bits === null) return `unmetered (\`${repo.sensitivity}\`)`;
- if (bits === 0) return `0 bits/run (\`${repo.sensitivity}\` — never runs an enclave)`;
- return `${bits} bits/run (\`${repo.sensitivity}\`)`;
-}
-
-export function generateBoundedAgentSkill(params: BoundedAgentSkillParams): string {
- const { repos, timeoutSeconds, maxInvocations, maxTaskBytes, engine } = params;
- const repoList = repos.map((repo) => `- \`${repo.repo}\` — ${formatRunBudget(repo)}`).join('\n');
- const bucketList = TIMING_BUCKETS_MS.map((ms) => (ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`)).join(', ');
-
- return `---
-name: bounded-agent
-description: >-
- Delegate a short, bounded task about one pre-approved private repository to
- an isolated enclave agent and get back a value conforming to a finite
- response schema you declare up front. Use when answering the question needs
- judgment or multi-step reading rather than a single deterministic script,
- and only when your remaining per-repository information budget can afford
- the answer's schema.
----
-
-# Bounded agent
-
-A bounded agent runs the configured native coding-agent engine inside a
-single-use enclave. The enclave reads a read-only copy of exactly one
-pre-approved private repository, reaches its configured model only through the
-AWF API proxy, and must reduce its work to one value conforming to the finite
-schema you declared.
-
-The enclave has no host access, no credentials, no workspace access, no Squid
-route, no general proxy, and no path to you, to the broker, to safe outputs, to
-the MCP gateway, or to the CLI proxy. Its only reachable peer is the AWF API
-proxy.
-
-You never see the repository contents, the enclave's transcript, its tool
-calls, its stdout/stderr, its files, its exit status, or any diagnostics. The
-only thing you observe is one canonical JSON result:
-
-- \`{"status":"ok","result":}\` where \`\` conforms to the exact
- response schema you declared, or
-- \`${CANONICAL_ERROR_JSON}\` for **every** failure mode (invalid request,
- disallowed repository, exhausted budget, launch failure, timeout, crash,
- non-conformant output, internal error). Failures are indistinguishable from
- each other by design — do not try to infer which one occurred.
-
-## Available repositories
-
-${repoList}
-
-Any other repository is rejected. The sensitivity and run budget shown above
-are fixed by AWF configuration; a request cannot choose or override them.
-
-## Invoking
-
-\`\`\`bash
-bounded-agent \\
- --repo owner/repo \\
- --schema '{"type":"boolean"}' \\
- < task.txt
-\`\`\`
-
-Rules enforced by the CLI:
-
-- exactly one \`--repo\`, and it must be one of the repositories listed above;
-- exactly one \`--schema\`: a finite response schema (see below);
-- the task text is read from stdin and must be at most ${maxTaskBytes} bytes;
-- there are no other options. You cannot choose the image, command,
- executable, engine, model, provider, profile, tools, system prompt, runtime,
- timeout, mount, path, network, proxy, endpoint, resource limit, environment,
- or credentials. Supplying any of them is rejected.
-
-The CLI always prints exactly one line of JSON and always exits \`0\`.
-
-## Response schema
-
-The schema is the same deliberately finite algebra bounded queries use — **not**
-general JSON Schema. Supported node types:
-
-| type | shape | notes |
-| --- | --- | --- |
-| \`const\` | \`{"type":"const","value":}\` | one fixed value |
-| \`boolean\` | \`{"type":"boolean"}\` | \`true\` or \`false\` |
-| \`enum\` | \`{"type":"enum","values":[,...]}\` | unique literals, same JSON type |
-| \`integer\` | \`{"type":"integer","minimum":N,"maximum":M}\` | inclusive bounded range |
-| \`object\` | \`{"type":"object","fields":{"name":,...}}\` | every field required, no extras |
-| \`tuple\` | \`{"type":"tuple","items":[,...]}\` | fixed-length, per-position schema |
-| \`array\` | \`{"type":"array","items":,"length":N}\` | fixed length, uniform item schema |
-| \`union\` | \`{"type":"union","variants":{"tag":,...}}\` | value is \`{"tag":"...","value":...}\` |
-
-There is no way to express an unbounded string, a float, a regex, recursion,
-\`$ref\`, an optional field, \`additionalProperties\`, or an untagged/overlapping
-union — these are structurally impossible, not merely disallowed. In
-particular, **a bounded agent cannot return prose**: if you want a summary,
-encode the finite set of conclusions you care about as an \`enum\`.
-
-## Task contract
-
-The task text is prompt input for the enclave, nothing else. It is never
-interpreted as configuration: it cannot add a tool, change the model, reach a
-network endpoint, or alter any limit.
-
-Inside the enclave the configured native agent has its built-in tools, including
-shell/Bash for the Copilot engine. The immutable seed remains read-only and all
-writable state is bounded tmpfs. The \`${engine}\` engine reaches its fixed
-model route through the AWF API proxy. Anything else in the output — wrong type,
-out-of-range value,
-unknown enum member, extra/missing fields, wrong length, malformed or
-duplicate-key JSON, an oversized result, no result at all — is reported to you
-as \`${CANONICAL_ERROR_JSON}\`.
-
-## Budget
-
-Every invocation reserves a fixed information charge from its repository's run
-budget, computed **before** any workspace or container is created:
-
-\`\`\`text
-charge = ${RESULT_STATUS_BIT_COST} (ok/error) + ceil(log2(schema cardinality)) + ${TIMING_BUCKET_BITS} (timing)
-\`\`\`
-
-The charge is debited whether the enclave succeeds, fails, or times out, and is
-never refunded. An invocation is only allowed if its charge fits the remaining
-balance. The remaining balance itself is never disclosed to you.
-
-Timing is charged because it is observable: the broker always returns at the
-first bucket boundary at or after the enclave actually finishes (bucket
-boundaries: ${bucketList}).
-
-- Each invocation may run for at most ${timeoutSeconds} second(s).
-- At most ${maxInvocations} invocation(s) are permitted for this entire run,
- independent of the bit budget above. Further calls return
- \`${CANONICAL_ERROR_JSON}\` without running anything.
-- Bounded agents keep a ledger **separate** from bounded queries: spending here
- does not consume a bounded query's balance, and vice versa.
-
-Design one high-value, low-cardinality question per invocation.
-`;
-}
-
-/**
- * Writes the generated skill into the agent-visible artifact directory.
- *
- * The file is securely created 0600 under an AWF-owned directory, then made
- * 0644 for the agent's read-only bind mount. Nothing is written to the host
- * user's home directory or to the workspace.
- */
-export function writeBoundedAgentSkill(paths: BoundedAgentPaths, params: BoundedAgentSkillParams): string {
- fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 });
- const content = generateBoundedAgentSkill(params);
- // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file
- // is already at this path (insecure-temp-file guard).
- const fd = fs.openSync(
- paths.skillPath,
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
- 0o600,
- );
- try {
- fs.writeSync(fd, content);
- // The protected 0755 parent exposes only this non-sensitive generated
- // guidance file; world-readability is required across the agent UID mount.
- fs.fchmodSync(fd, 0o644);
- } finally {
- fs.closeSync(fd);
- }
- return AGENT_SKILL_PATH;
-}
diff --git a/src/bounded-agent/staging.ts b/src/bounded-agent/staging.ts
deleted file mode 100644
index 382e3a02d..000000000
--- a/src/bounded-agent/staging.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-import type { BoundedAgentRepository } from '../types/bounded-agent-options';
-import {
- stageBoundedQuerySeeds,
- type GitRunner,
-} from '../bounded-query/staging';
-import type { PrivateRepositoryStagingResult } from '../bounded-execution/repository-staging';
-import type { BoundedAgentPaths } from './paths';
-
-/**
- * Trusted host-side staging for bounded agents.
- *
- * Staging is *identical work* to bounded queries — clone one immutable seed
- * per configured repository, scrub every credential/hook/external-reference
- * artifact, make it read-only, and verify that — so this module deliberately
- * delegates to the audited implementation in `../bounded-query/staging.ts`
- * rather than restating it. What differs is only the destination: bounded
- * agents stage into their own disjoint private root (see `./paths.ts`), so the
- * two subsystems never share a seed, a workspace, an audit log, or a ledger.
- *
- * The staging credential is read from the AWF host environment, used only by
- * this phase through a `GIT_ASKPASS` helper reading a 0600 file, and scrubbed
- * before the broker, the enclave, or the primary agent exists.
- */
-
-export interface StageBoundedAgentSeedsParams {
- /** Trusted repository descriptors exactly as configured (already schema-validated). */
- repos: BoundedAgentRepository[];
- /** Resolved bounded-agent filesystem layout. */
- paths: Pick;
- /** Run-unique id used to derive opaque seed directory names. */
- runId: string;
- /** Staging credential. Never logged, never forwarded past the staging phase. */
- token: string;
- /** Override the git runner (tests). */
- gitRunner?: GitRunner;
-}
-
-/** Materializes an immutable seed for every configured bounded-agent repository. */
-function makeSeedEnclaveReadable(target: string): void {
- const stat = fs.lstatSync(target);
- if (stat.isSymbolicLink()) return;
-
- if (stat.isDirectory()) {
- for (const entry of fs.readdirSync(target)) {
- makeSeedEnclaveReadable(path.join(target, entry));
- }
- fs.chmodSync(target, (stat.mode & 0o7777) | 0o555);
- return;
- }
-
- fs.chmodSync(target, (stat.mode & 0o7777) | 0o444);
-}
-
-export async function stageBoundedAgentSeeds(
- params: StageBoundedAgentSeedsParams,
-): Promise {
- const result = await stageBoundedQuerySeeds({
- repos: params.repos,
- paths: params.paths,
- runId: params.runId,
- token: params.token,
- gitRunner: params.gitRunner,
- label: 'Bounded agents',
- });
-
- // The enclave runs as fixed uid/gid 65534 and bind-mounts the immutable seed
- // directly. Grant read/traverse permission without restoring any write bit.
- for (const seed of result.seeds) {
- makeSeedEnclaveReadable(seed.seedPath);
- }
- return result;
-}
-
-export { releaseSeedPermissions, resolveStagingToken } from '../bounded-query/staging';
-export type { GitRunner } from '../bounded-query/staging';
-
-/** @internal Exported for focused permission tests. */
-// ts-prune-ignore-next
-export const boundedAgentStagingTestHelpers = { makeSeedEnclaveReadable };
diff --git a/src/bounded-agent/workflow-integration.test.ts b/src/bounded-agent/workflow-integration.test.ts
deleted file mode 100644
index 1037263b5..000000000
--- a/src/bounded-agent/workflow-integration.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { runMainWorkflow } from '../cli-workflow';
-import type { WrapperConfig } from '../types';
-import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options';
-
-jest.mock('../topology', () => ({
- TOPOLOGY_NETWORK_NAME: 'awf-net',
- getTopologyContainerIps: jest.fn(),
- patchComposeWithTopologyHosts: jest.fn(),
- connectTopologyContainers: jest.fn(),
- assertTopologySupported: jest.fn(),
-}));
-
-jest.mock('../container-runtime', () => ({
- runtimeNeedsStaticDns: jest.fn().mockReturnValue(false),
- runtimeUsesComposeAgent: jest.fn().mockReturnValue(true),
-}));
-
-/**
- * Lifecycle ordering guarantees for bounded agents.
- *
- * Preflight + staging are credential-bearing and must complete before anything
- * untrusted exists, so they run ahead of config generation, host network setup,
- * and container startup — and a failure must stop the run before the primary
- * agent is invoked. Bounded queries must remain independently wired.
- */
-
-const boundedAgents: BoundedAgentsConfig = {
- ...BOUNDED_AGENT_DEFAULTS,
- enabled: true,
- model: 'gpt-4o-mini',
- privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
-};
-
-const baseConfig: WrapperConfig = {
- allowedDomains: ['github.com'],
- agentCommand: 'echo hi',
- logLevel: 'info',
- keepContainers: false,
- workDir: '/tmp/awf-bounded-agent-workflow',
- imageRegistry: 'registry',
- imageTag: 'latest',
- buildLocal: false,
-} as WrapperConfig;
-
-function createDeps(callOrder: string[], overrides: Record = {}) {
- return {
- ensureFirewallNetwork: jest.fn().mockImplementation(async () => {
- callOrder.push('ensureFirewallNetwork');
- return { squidIp: '172.30.0.10', agentIp: '172.30.0.20', proxyIp: '172.30.0.30', subnet: '172.30.0.0/24' };
- }),
- setupHostIptables: jest.fn().mockImplementation(async () => {
- callOrder.push('setupHostIptables');
- }),
- writeConfigs: jest.fn().mockImplementation(async () => {
- callOrder.push('writeConfigs');
- }),
- startContainers: jest.fn().mockImplementation(async () => {
- callOrder.push('startContainers');
- }),
- runAgentCommand: jest.fn().mockImplementation(async () => {
- callOrder.push('runAgentCommand');
- return { exitCode: 0 };
- }),
- prepareBoundedQueries: jest.fn().mockImplementation(async () => {
- callOrder.push('prepareBoundedQueries');
- }),
- prepareBoundedAgents: jest.fn().mockImplementation(async () => {
- callOrder.push('prepareBoundedAgents');
- }),
- ...overrides,
- } as unknown as Parameters[1];
-}
-
-function createOptions() {
- return {
- logger: { info: jest.fn(), success: jest.fn(), warn: jest.fn() },
- performCleanup: jest.fn().mockResolvedValue(undefined),
- } as unknown as Parameters[2];
-}
-
-describe('bounded-agent staging in the main workflow', () => {
- it('stages seeds before configs are written and containers start', async () => {
- const callOrder: string[] = [];
- const deps = createDeps(callOrder);
-
- await runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions());
-
- expect(callOrder[0]).toBe('prepareBoundedAgents');
- expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('writeConfigs'));
- expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('startContainers'));
- expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('ensureFirewallNetwork'));
- });
-
- it('does not stage anything when bounded agents are disabled', async () => {
- const callOrder: string[] = [];
- const deps = createDeps(callOrder);
-
- await runMainWorkflow(baseConfig, deps, createOptions());
-
- expect(callOrder).not.toContain('prepareBoundedAgents');
- expect((deps as unknown as { prepareBoundedAgents: jest.Mock }).prepareBoundedAgents)
- .not.toHaveBeenCalled();
- });
-
- it('aborts before the primary agent runs when preflight or staging fails', async () => {
- const callOrder: string[] = [];
- const deps = createDeps(callOrder, {
- prepareBoundedAgents: jest.fn().mockRejectedValue(new Error('runsc is not registered')),
- });
-
- await expect(
- runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()),
- ).rejects.toThrow('runsc is not registered');
-
- expect(callOrder).toEqual([]);
- expect((deps as unknown as { writeConfigs: jest.Mock }).writeConfigs).not.toHaveBeenCalled();
- expect((deps as unknown as { startContainers: jest.Mock }).startContainers).not.toHaveBeenCalled();
- expect((deps as unknown as { runAgentCommand: jest.Mock }).runAgentCommand).not.toHaveBeenCalled();
- });
-
- it('refuses to run when bounded agents are enabled but no staging implementation was injected', async () => {
- const callOrder: string[] = [];
- const deps = createDeps(callOrder, { prepareBoundedAgents: undefined });
-
- await expect(
- runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()),
- ).rejects.toThrow(/no staging implementation/);
-
- expect(callOrder).toEqual([]);
- });
-
- it('leaves bounded-query staging independently wired', async () => {
- const callOrder: string[] = [];
- const deps = createDeps(callOrder);
-
- await runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions());
- expect(callOrder).not.toContain('prepareBoundedQueries');
-
- callOrder.length = 0;
- await runMainWorkflow(
- {
- ...baseConfig,
- boundedAgents,
- boundedQueries: {
- enabled: true,
- privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
- runtime: 'docker',
- timeout: 30,
- memoryLimit: '512m',
- interpreter: 'python3',
- maxInvocations: 32,
- },
- },
- deps,
- createOptions(),
- );
- expect(callOrder.indexOf('prepareBoundedQueries')).toBeLessThan(
- callOrder.indexOf('prepareBoundedAgents'),
- );
- });
-});
diff --git a/src/bounded-agent/workspace-artifacts.test.ts b/src/bounded-agent/workspace-artifacts.test.ts
deleted file mode 100644
index 9719c8eb9..000000000
--- a/src/bounded-agent/workspace-artifacts.test.ts
+++ /dev/null
@@ -1,295 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import { spawnSync } from 'child_process';
-import { generateBoundedAgentSkill, writeBoundedAgentSkill } from './skill';
-import { writeBoundedAgentWrapper } from './wrapper-artifact';
-import { resolveBoundedAgentPaths } from './paths';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker');
-const workspace = require(path.join(brokerDir, 'workspace.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-describe('bounded-agent invocation workspace', () => {
- let root: string;
- let config: Record;
-
- beforeEach(() => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ws-'));
- config = { workDir: root, enclaveUid: process.getuid?.() ?? 0, enclaveGid: process.getgid?.() ?? 0 };
- });
-
- afterEach(() => {
- fs.rmSync(root, { recursive: true, force: true });
- });
-
- it('materializes only task, schema, result, and session files — never a repository copy', () => {
- const layout = workspace.createInvocationWorkspace({
- config,
- invocationId: 'abc123',
- task: 'the task',
- schema: { type: 'boolean' },
- });
-
- expect(fs.readdirSync(layout.root).sort()).toEqual(['out', 'schema.json', 'session.jsonl', 'task.txt']);
- expect(fs.readFileSync(layout.taskPath, 'utf8')).toBe('the task');
- expect(JSON.parse(fs.readFileSync(layout.schemaPath, 'utf8'))).toEqual({ type: 'boolean' });
- expect(fs.readFileSync(layout.outPath, 'utf8')).toBe('');
- expect(fs.readFileSync(layout.sessionLogPath, 'utf8')).toBe('');
- });
-
- it('makes the task and schema read-only inside the enclave mount source', () => {
- const layout = workspace.createInvocationWorkspace({
- config,
- invocationId: 'abc123',
- task: 'the task',
- schema: { type: 'boolean' },
- });
- expect(fs.statSync(layout.taskPath).mode & 0o222).toBe(0);
- expect(fs.statSync(layout.schemaPath).mode & 0o222).toBe(0);
- });
-
- it('reads back a result of exactly the permitted size', () => {
- const layout = workspace.createInvocationWorkspace({
- config,
- invocationId: 'abc123',
- task: 't',
- schema: { type: 'boolean' },
- });
- fs.writeFileSync(layout.outPath, 'true');
- expect(workspace.readEnclaveOutput(layout.outPath, 4)).toBe('true');
- expect(workspace.readEnclaveOutput(layout.outPath, 3)).toBeUndefined();
- });
-
- it('rejects a missing result file', () => {
- expect(workspace.readEnclaveOutput(path.join(root, 'nope'), 100)).toBeUndefined();
- });
-
- it('rejects a symlinked result file', () => {
- const target = path.join(root, 'secret');
- fs.writeFileSync(target, 'true');
- const link = path.join(root, 'out');
- fs.symlinkSync(target, link);
- expect(workspace.readEnclaveOutput(link, 100)).toBeUndefined();
- });
-
- it('rejects a non-regular result file', () => {
- const fifo = path.join(root, 'fifo');
- fs.mkdirSync(fifo);
- expect(workspace.readEnclaveOutput(fifo, 100)).toBeUndefined();
- });
-
- it('rejects invalid UTF-8', () => {
- const out = path.join(root, 'out');
- fs.writeFileSync(out, Buffer.from([0xff, 0xfe, 0xfd]));
- expect(workspace.readEnclaveOutput(out, 100)).toBeUndefined();
- });
-
- it('preserves a bounded regular session log in the private audit directory', () => {
- const layout = workspace.createInvocationWorkspace({
- config,
- invocationId: 'abc123',
- task: 't',
- schema: { type: 'boolean' },
- });
- fs.writeFileSync(layout.sessionLogPath, '{"event":"session"}\n');
- const auditDir = path.join(root, 'audit');
-
- expect(workspace.preserveInvocationSession(layout.sessionLogPath, auditDir, 'abc123')).toBe(true);
- const preserved = path.join(auditDir, 'sessions', 'abc123.jsonl');
- expect(fs.readFileSync(preserved, 'utf8')).toBe('{"event":"session"}\n');
- expect(fs.statSync(preserved).mode & 0o777).toBe(0o600);
- });
-
- it('refuses to preserve a symlinked session log', () => {
- const target = path.join(root, 'private');
- const link = path.join(root, 'session.jsonl');
- fs.writeFileSync(target, 'private');
- fs.symlinkSync(target, link);
- expect(workspace.preserveInvocationSession(link, path.join(root, 'audit'), 'abc123')).toBe(false);
- });
-
- it('destroys the workspace idempotently', () => {
- workspace.createInvocationWorkspace({
- config,
- invocationId: 'abc123',
- task: 't',
- schema: { type: 'boolean' },
- });
- workspace.destroyInvocationWorkspace(root, 'abc123');
- workspace.destroyInvocationWorkspace(root, 'abc123');
- expect(fs.existsSync(path.join(root, 'abc123'))).toBe(false);
- });
-});
-
-describe('generated bounded-agent skill', () => {
- const params = {
- repos: [
- { repo: 'octo/alpha', sensitivity: 'internal' as const },
- { repo: 'octo/sealed', sensitivity: 'sealed' as const },
- { repo: 'octo/open', sensitivity: 'public' as const },
- ],
- timeoutSeconds: 120,
- maxInvocations: 8,
- maxTaskBytes: 4096,
- engine: 'copilot' as const,
- };
-
- it('lists each repository with its fixed run budget', () => {
- const skill = generateBoundedAgentSkill(params);
- expect(skill).toContain('`octo/alpha` — 64 bits/run (`internal`)');
- expect(skill).toContain('`octo/sealed` — 0 bits/run (`sealed` — never runs an enclave)');
- expect(skill).toContain('`octo/open` — unmetered (`public`)');
- });
-
- it('documents the canonical envelopes and the fixed CLI surface', () => {
- const skill = generateBoundedAgentSkill(params);
- expect(skill).toContain('{"status":"error"}');
- expect(skill).toContain('{"status":"ok","result":}');
- expect(skill).toContain('exactly one `--repo`');
- expect(skill).toContain('exactly one `--schema`');
- });
-
- it('states that no capability-bearing option exists', () => {
- const skill = generateBoundedAgentSkill(params);
- for (const forbidden of [
- 'image', 'command', 'executable', 'engine', 'model', 'provider', 'profile', 'tools', 'system prompt',
- 'runtime', 'timeout', 'mount', 'path', 'network', 'proxy', 'endpoint', 'resource limit',
- 'environment', 'credentials',
- ]) {
- expect(skill).toContain(forbidden);
- }
- });
-
- it('never discloses the remaining budget', () => {
- const skill = generateBoundedAgentSkill(params);
- expect(skill).toContain('remaining balance itself is never disclosed');
- });
-
- it('states that the ledger is separate from bounded queries', () => {
- expect(generateBoundedAgentSkill(params)).toContain('ledger **separate** from bounded queries');
- });
-});
-
-describe('bounded-agent agent artifacts', () => {
- let workDir: string;
-
- beforeEach(() => {
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-artifacts-'));
- });
-
- afterEach(() => {
- fs.rmSync(resolveBoundedAgentPaths(workDir).ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('writes a world-readable skill and an executable wrapper into the ingress root only', () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 });
-
- writeBoundedAgentSkill(paths, {
- repos: [{ repo: 'octo/alpha', sensitivity: 'internal' }],
- timeoutSeconds: 120,
- maxInvocations: 8,
- maxTaskBytes: 4096,
- engine: 'copilot',
- });
- writeBoundedAgentWrapper(paths);
-
- expect(fs.statSync(paths.skillPath).mode & 0o777).toBe(0o644);
- // Open with O_NOFOLLOW to avoid TOCTOU between stat and read.
- const wrapperFd = fs.openSync(paths.wrapperPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
- try {
- expect(fs.fstatSync(wrapperFd).mode & 0o777).toBe(0o555);
- expect(fs.readFileSync(wrapperFd, 'utf8')).toContain('AWF_BOUNDED_AGENT_SOCKET');
- } finally {
- fs.closeSync(wrapperFd);
- }
- // Nothing is written into the broker-private root.
- expect(fs.existsSync(paths.root)).toBe(false);
- });
-
- it('refuses to overwrite a pre-existing artifact', () => {
- const paths = resolveBoundedAgentPaths(workDir);
- fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 });
- fs.writeFileSync(paths.wrapperPath, 'planted');
-
- expect(() => writeBoundedAgentWrapper(paths)).toThrow(/EEXIST/);
- });
-});
-
-describe('bounded-agent CLI wrapper source', () => {
- const wrapper = fs.readFileSync(
- path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'),
- 'utf8',
- );
-
- it('accepts only --repo and --schema plus stdin', () => {
- expect(wrapper).toContain('--repo)');
- expect(wrapper).toContain('--schema)');
- // Everything else falls through to the canonical error.
- expect(wrapper).toContain('*)\n # Any other flag');
- });
-
- it('always emits a canonical envelope and exits 0', () => {
- expect(wrapper).toContain("CANONICAL_ERROR='{\"status\":\"error\"}'");
- expect(wrapper).not.toMatch(/exit\s+[1-9]/);
- });
-
- it('never forwards a proxy, credential, or runtime control', () => {
- expect(wrapper).toContain("--noproxy '*'");
- for (const forbidden of ['AWF_BOUNDED_AGENT_MODEL', 'Authorization', 'X-AWF-Runtime']) {
- expect(wrapper).not.toContain(forbidden);
- }
- });
-
- it('uses the authenticated host-gateway endpoint only when the sbx transport is complete', () => {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-wrapper-'));
- const argsPath = path.join(root, 'curl.args');
- const fakeCurl = path.join(root, 'curl');
- fs.writeFileSync(
- fakeCurl,
- `#!/bin/sh\nprintf '%s\\n' "$@" > "$AWF_TEST_CURL_ARGS"\nprintf '%s' '{"status":"error"}'\n`,
- { mode: 0o755 },
- );
- try {
- const capability = 'a'.repeat(64);
- const result = spawnSync(
- '/bin/sh',
- [
- path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'),
- '--repo',
- 'octo/alpha',
- '--schema',
- '{"type":"boolean"}',
- ],
- {
- input: 'bounded task',
- encoding: 'utf8',
- env: {
- PATH: `${root}:${process.env.PATH ?? ''}`,
- AWF_TEST_CURL_ARGS: argsPath,
- AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:18081/query',
- AWF_BOUNDED_AGENT_CAPABILITY: capability,
- },
- },
- );
-
- expect(result.status).toBe(0);
- expect(result.stdout).toBe('{"status":"error"}\n');
- expect(result.stderr).toBe('');
- const curlArgs = fs.readFileSync(argsPath, 'utf8');
- expect(curlArgs).toContain('X-AWF-Capability: ' + capability);
- expect(curlArgs).toContain('http://host.docker.internal:18081/query');
- expect(curlArgs).toContain('--noproxy');
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
- });
-
- it('only ever passes through the two canonical response shapes', () => {
- expect(wrapper).toContain('\'{"status":"error"}\')');
- expect(wrapper).toContain('\'{"status":"ok","result":\'*\'}\')');
- });
-});
diff --git a/src/bounded-agent/wrapper-artifact.test.ts b/src/bounded-agent/wrapper-artifact.test.ts
deleted file mode 100644
index 46c2b320b..000000000
--- a/src/bounded-agent/wrapper-artifact.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import { writeBoundedAgentWrapper } from './wrapper-artifact';
-import type { BoundedAgentPaths } from './paths';
-
-jest.mock('fs', () => ({
- ...jest.requireActual('fs'),
- existsSync: jest.fn(),
- readFileSync: jest.fn(),
-}));
-
-const mockExistsSync = fs.existsSync as jest.MockedFunction;
-const mockReadFileSync = fs.readFileSync as jest.MockedFunction;
-const actualFs = jest.requireActual('fs');
-
-describe('writeBoundedAgentWrapper source resolution', () => {
- let dir: string;
- let paths: BoundedAgentPaths;
-
- beforeEach(() => {
- mockExistsSync.mockReset();
- mockReadFileSync.mockReset();
- dir = actualFs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-wrapper-'));
- paths = { wrapperPath: path.join(dir, 'bounded-agent') } as BoundedAgentPaths;
- });
-
- afterEach(() => {
- actualFs.rmSync(dir, { recursive: true, force: true });
- });
-
- it('uses the packaged fallback candidate when the source-tree candidate is absent', () => {
- mockExistsSync
- .mockReturnValueOnce(false)
- .mockReturnValueOnce(true);
- mockReadFileSync.mockReturnValue('#!/bin/sh\n');
-
- expect(writeBoundedAgentWrapper(paths)).toBe(paths.wrapperPath);
- expect(actualFs.readFileSync(paths.wrapperPath, 'utf8')).toBe('#!/bin/sh\n');
- });
-
- it('fails closed when neither fixed wrapper candidate exists', () => {
- mockExistsSync.mockReturnValue(false);
- expect(() => writeBoundedAgentWrapper(paths)).toThrow(/Bounded-agent wrapper not found/);
- });
-});
diff --git a/src/bounded-agent/wrapper-artifact.ts b/src/bounded-agent/wrapper-artifact.ts
deleted file mode 100644
index e4430ffff..000000000
--- a/src/bounded-agent/wrapper-artifact.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-import type { BoundedAgentPaths } from './paths';
-
-// In the standalone bundle this global is replaced at build time with the
-// wrapper source. Normal source/npm builds read the checked-in shell script.
-declare const __AWF_BOUNDED_AGENT_WRAPPER__: string | undefined;
-
-function loadWrapperSource(): string {
- if (typeof __AWF_BOUNDED_AGENT_WRAPPER__ !== 'undefined') {
- return __AWF_BOUNDED_AGENT_WRAPPER__;
- }
-
- const candidates = [
- path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'),
- path.join(__dirname, '..', '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'),
- ];
- for (const candidate of candidates) {
- if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
- }
- throw new Error(`Bounded-agent wrapper not found at ${candidates.join(' or ')}`);
-}
-
-/** Materializes the wrapper in the agent-only ingress root. */
-export function writeBoundedAgentWrapper(paths: BoundedAgentPaths): string {
- const fd = fs.openSync(
- paths.wrapperPath,
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
- 0o700,
- );
- try {
- fs.writeSync(fd, loadWrapperSource());
- fs.fchmodSync(fd, 0o555);
- } finally {
- fs.closeSync(fd);
- }
- return paths.wrapperPath;
-}
diff --git a/src/bounded-execution/compatibility.test.ts b/src/bounded-execution/compatibility.test.ts
deleted file mode 100644
index a0748720d..000000000
--- a/src/bounded-execution/compatibility.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import {
- BOUNDED_QUERY_SEED_MAP_VERSION,
- CANONICAL_ERROR_JSON,
- PRIVATE_REPOSITORY_SEED_MAP_VERSION,
- canonicalOkJson,
- informationChargeForSchema,
- queryBitsForSchema,
- serializePrivateRepositorySeedMap,
- validateFiniteSchema,
- validateSchema,
- type BoundedQuerySeedMap,
- type PrivateRepositorySeedMap,
-} from './index';
-import * as boundedQueryProtocol from '../bounded-query/protocol';
-import * as boundedQueryTypes from '../bounded-query/types';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const sharedRuntime = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'bounded-execution'),
-);
-const queryProtocol = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'protocol.js'),
-);
-const queryLedger = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'ledger.js'),
-);
-const queryScheduler = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'scheduler.js'),
-);
-const queryAudit = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'audit.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-describe('bounded-execution compatibility foundation', () => {
- it('keeps TypeScript bounded-query exports on the shared implementations', () => {
- expect(boundedQueryProtocol.validateSchema).toBe(validateSchema);
- expect(boundedQueryProtocol.canonicalOkJson).toBe(canonicalOkJson);
- expect(boundedQueryTypes.BOUNDED_QUERY_SEED_MAP_VERSION).toBe(BOUNDED_QUERY_SEED_MAP_VERSION);
- });
-
- it('preserves schema acceptance, rejection, canonical bytes, and information charges', () => {
- const accepted = { type: 'object', fields: { z: { type: 'boolean' }, a: { type: 'boolean' } } };
- const rejected = { type: 'object', fields: {} };
- const validation = validateFiniteSchema(accepted);
- expect(validation).toEqual(validateSchema(accepted));
- expect(validateFiniteSchema(rejected)).toEqual(validateSchema(rejected));
- if (!validation.valid) throw new Error('expected accepted schema');
-
- expect(informationChargeForSchema(validation.schema)).toBe(queryBitsForSchema(validation.schema));
- expect(canonicalOkJson('{"a":false,"z":true}')).toBe(
- '{"status":"ok","result":{"a":false,"z":true}}',
- );
- expect(CANONICAL_ERROR_JSON).toBe('{"status":"error"}');
- });
-
- it('keeps broker compatibility modules identical to the shared runtime', () => {
- expect(queryProtocol.validateSchema).toBe(sharedRuntime.validateSchema);
- expect(queryProtocol.canonicalOkJson('"ok"')).toBe(sharedRuntime.canonicalSuccessJson('"ok"'));
- expect(queryLedger.createLedger).toBe(sharedRuntime.createSensitivityLedger);
- expect(queryScheduler.resolveTimingBucket).toBe(sharedRuntime.resolveTimingBucket);
- expect(queryAudit.createAuditLog).toBe(sharedRuntime.createProtectedAuditLog);
- });
-
- it('preserves ledger debit decisions and fixed timing bucket choice', () => {
- const seeds = new Map([['octo/private', { seedId: 'a'.repeat(32), sensitivity: 'confidential' }]]);
- const legacy = queryLedger.createLedger(seeds);
- const shared = sharedRuntime.createSensitivityLedger(seeds);
-
- for (const charge of [4, 4, 1]) {
- expect(shared.tryDebit('octo/private', charge)).toBe(legacy.tryDebit('octo/private', charge));
- expect(shared.remainingBits('octo/private')).toBe(legacy.remainingBits('octo/private'));
- }
- for (const elapsed of [0, 10, 11, 100, 60_001, 600_001]) {
- expect(sharedRuntime.resolveTimingBucket(elapsed)).toEqual(queryScheduler.resolveTimingBucket(elapsed));
- }
- });
-
- it('preserves protected audit detail bounding and canonical record shape', () => {
- const detail = `secret-${'x'.repeat(sharedRuntime.MAX_REASON_LENGTH + 20)}`;
- expect(sharedRuntime.redactAuditDetail(detail)).toBe(detail.slice(0, sharedRuntime.MAX_REASON_LENGTH));
-
- const auditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-execution-audit-'));
- try {
- const audit = sharedRuntime.createProtectedAuditLog(auditDir);
- audit.failure('invocation-1', 'query-error', detail);
- const record = JSON.parse(
- fs.readFileSync(path.join(auditDir, 'bounded-query.jsonl'), 'utf8').trim(),
- );
- expect(record).toMatchObject({
- kind: 'failure',
- invocationId: 'invocation-1',
- reason: 'query-error',
- detail: detail.slice(0, sharedRuntime.MAX_REASON_LENGTH),
- });
- expect(Object.keys(record).sort()).toEqual(
- ['ts', 'kind', 'invocationId', 'reason', 'detail'].sort(),
- );
- } finally {
- fs.rmSync(auditDir, { recursive: true, force: true });
- }
- });
-
- it('keeps private repository staging descriptors and serialized bytes unchanged', () => {
- expect(PRIVATE_REPOSITORY_SEED_MAP_VERSION).toBe(BOUNDED_QUERY_SEED_MAP_VERSION);
- const shared: PrivateRepositorySeedMap = {
- version: PRIVATE_REPOSITORY_SEED_MAP_VERSION,
- runId: 'f'.repeat(32),
- seeds: [{ repo: 'octo/private', seedId: 'a'.repeat(32), sensitivity: 'internal' }],
- };
- const legacy: BoundedQuerySeedMap = shared;
-
- const expected = JSON.stringify(legacy, null, 2) + '\n';
- expect(serializePrivateRepositorySeedMap(shared)).toBe(expected);
-
- const parsed = sharedRuntime.parsePrivateRepositorySeedMap(
- expected,
- sharedRuntime.SENSITIVITY_RUN_BITS,
- );
- expect(parsed).toEqual({
- runId: shared.runId,
- seeds: new Map([
- ['octo/private', { seedId: 'a'.repeat(32), sensitivity: 'internal' }],
- ]),
- });
- });
-});
diff --git a/src/bounded-execution/finite-disclosure.ts b/src/bounded-execution/finite-disclosure.ts
index be8caa7d5..9d5b6b06a 100644
--- a/src/bounded-execution/finite-disclosure.ts
+++ b/src/bounded-execution/finite-disclosure.ts
@@ -1,15 +1,15 @@
/**
- * Bounded-query request/result protocol v2: a deliberately finite,
+ * Enclave finite-disclosure protocol v2: a deliberately finite,
* agent-authored response-schema algebra plus request/result validation and
* canonicalization.
*
- * This module defines the wire protocol for bounded queries independently of
+ * This module defines the enclave finite-disclosure wire protocol independently of
* any broker or sandbox runtime.
*
* Protocol summary:
* - A **request** asks the trusted broker to run an agent-authored Python
* script against a private repository and report a value conforming to
- * an agent-authored, but AWF-bounded, finite response **schema**.
+ * a caller-authored finite response **schema** constrained by AWF.
* - The schema is drawn from a small, closed algebra (`const`, `boolean`,
* unique `enum`, bounded `integer`, fixed `object`, `tuple`, fixed-length
* `array`, and tagged `union`) — general JSON Schema is not accepted.
@@ -35,11 +35,10 @@
* or query output) can grow an unbounded parse tree, and duplicate object
* keys — which `JSON.parse` would silently collapse — are rejected outright.
*
- * `containers/bounded-query/bounded-execution/finite-disclosure.js` is a deliberate,
- * behaviour-identical mirror of this module for the broker's container
+ * `containers/bounded-execution/finite-disclosure.js` is a deliberate,
+ * behaviour-identical mirror of this module for the enclave server
* image, which cannot import AWF's TypeScript sources. Keep both in sync;
- * `src/bounded-query/protocol-parity.test.ts` runs shared vectors through
- * both and fails the moment they disagree.
+ * enclave protocol tests run shared vectors through both.
*/
/** Wire protocol version. Only this exact value is accepted. */
@@ -92,7 +91,7 @@ export const TIMING_BUCKETS_MS: readonly number[] = [10, 100, 1_000, 10_000, 60_
export const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000;
/** Largest configurable script timeout while preserving the final-bucket margin. */
-export const MAX_QUERY_TIMEOUT_SECONDS =
+export const MAX_ENCLAVE_TIMEOUT_SECONDS =
(TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000;
/**
@@ -110,11 +109,11 @@ export const RESULT_STATUS_BIT_COST = 1;
* traversal (`..`), no query string or fragment (`?`/`#`), no wildcard
* (`*`), and no extra path segments (only one `/` is allowed).
*
- * Keep in sync with `boundedQueries.privateRepos.items` in
+ * Keep in sync with `enclaves.privateRepos.items` in
* `docs/awf-config.schema.json` (JSON Schema cannot share a regex constant
* with TypeScript source).
*/
-export const BOUNDED_QUERY_REPO_PATTERN =
+export const PRIVATE_REPOSITORY_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/;
/** Bounded ASCII identifier accepted for object field names and union tags. */
@@ -160,20 +159,20 @@ export interface IntegerSchemaNode {
}
export interface ObjectSchemaNode {
readonly type: 'object';
- readonly fields: readonly { name: string; schema: BoundedQuerySchemaNode }[];
+ readonly fields: readonly { name: string; schema: FiniteSchemaNode }[];
}
export interface TupleSchemaNode {
readonly type: 'tuple';
- readonly items: readonly BoundedQuerySchemaNode[];
+ readonly items: readonly FiniteSchemaNode[];
}
export interface ArraySchemaNode {
readonly type: 'array';
- readonly items: BoundedQuerySchemaNode;
+ readonly items: FiniteSchemaNode;
readonly length: number;
}
export interface UnionSchemaNode {
readonly type: 'union';
- readonly variants: readonly { tag: string; schema: BoundedQuerySchemaNode }[];
+ readonly variants: readonly { tag: string; schema: FiniteSchemaNode }[];
}
/**
@@ -184,7 +183,7 @@ export interface UnionSchemaNode {
* literal sizes). Cardinality, value validation, and canonical serialization
* below all assume that.
*/
-export type BoundedQuerySchemaNode =
+export type FiniteSchemaNode =
| ConstSchemaNode
| BooleanSchemaNode
| EnumSchemaNode
@@ -194,8 +193,8 @@ export type BoundedQuerySchemaNode =
| ArraySchemaNode
| UnionSchemaNode;
-export type BoundedQuerySchemaValidation =
- | { valid: true; schema: BoundedQuerySchemaNode }
+export type FiniteSchemaValidation =
+ | { valid: true; schema: FiniteSchemaNode }
| { valid: false; errors: string[] };
function isValidLiteral(value: unknown): value is JsonLiteral {
@@ -223,11 +222,11 @@ function failSchema(ctx: SchemaParseContext, message: string): undefined {
}
/**
- * Builds one validated {@link BoundedQuerySchemaNode}, enforcing every finite
+ * Builds one validated {@link FiniteSchemaNode}, enforcing every finite
* bound as it recurses. Stops at the first violation (`ctx.errors` becomes
* non-empty) rather than continuing to build a tree that will be discarded.
*/
-function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): BoundedQuerySchemaNode | undefined {
+function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): FiniteSchemaNode | undefined {
if (ctx.errors.length > 0) return undefined;
if (depth > MAX_SCHEMA_DEPTH) {
return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`);
@@ -320,7 +319,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number):
return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`);
}
}
- const fields: { name: string; schema: BoundedQuerySchemaNode }[] = [];
+ const fields: { name: string; schema: FiniteSchemaNode }[] = [];
for (const name of fieldNames) {
const child = buildSchemaNode((fieldsRaw as Record)[name], ctx, depth + 1);
if (!child) return undefined;
@@ -339,7 +338,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number):
if (itemsRaw.length > MAX_TUPLE_ITEMS) {
return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`);
}
- const items: BoundedQuerySchemaNode[] = [];
+ const items: FiniteSchemaNode[] = [];
for (const itemRaw of itemsRaw) {
const child = buildSchemaNode(itemRaw, ctx, depth + 1);
if (!child) return undefined;
@@ -379,7 +378,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number):
return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`);
}
}
- const variants: { tag: string; schema: BoundedQuerySchemaNode }[] = [];
+ const variants: { tag: string; schema: FiniteSchemaNode }[] = [];
for (const tag of tags) {
const child = buildSchemaNode((variantsRaw as Record)[tag], ctx, depth + 1);
if (!child) return undefined;
@@ -404,7 +403,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number):
* untagged unions are all structurally impossible to express, so they are
* rejected by construction rather than by a separate deny-list.
*/
-export function validateSchema(raw: unknown): BoundedQuerySchemaValidation {
+export function validateSchema(raw: unknown): FiniteSchemaValidation {
let serialized: string;
try {
serialized = JSON.stringify(raw) ?? '';
@@ -440,7 +439,7 @@ export function ceilLog2BigInt(n: bigint): number {
* distinguishable valid values) as a `BigInt`, so it can never silently
* overflow even for schemas near the configured bounds.
*/
-export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint {
+export function schemaCardinality(schema: FiniteSchemaNode): bigint {
switch (schema.type) {
case 'const':
return 1n;
@@ -492,7 +491,7 @@ function cappedPower(base: bigint, exponent: number): bigint {
return result;
}
-function cappedSchemaCardinality(schema: BoundedQuerySchemaNode): bigint {
+function cappedSchemaCardinality(schema: FiniteSchemaNode): bigint {
switch (schema.type) {
case 'const':
return 1n;
@@ -537,7 +536,7 @@ function cappedSchemaCardinality(schema: BoundedQuerySchemaNode): bigint {
* copying a seed or launching Python — never refunded, regardless of the
* actual result or completion bucket.
*/
-export function queryBitsForSchema(schema: BoundedQuerySchemaNode): number {
+export function informationChargeForSchema(schema: FiniteSchemaNode): number {
return RESULT_STATUS_BIT_COST + ceilLog2BigInt(cappedSchemaCardinality(schema)) + TIMING_BUCKET_BITS;
}
@@ -553,7 +552,7 @@ function jsonLiteralEquals(value: unknown, literal: JsonLiteral): boolean {
* object/tuple/array shape (no extras, no missing fields, exact length), and
* an explicit tagged-union variant. Never coerces.
*/
-export function validateValueAgainstSchema(schema: BoundedQuerySchemaNode, value: unknown): boolean {
+export function validateValueAgainstSchema(schema: FiniteSchemaNode, value: unknown): boolean {
switch (schema.type) {
case 'const':
return jsonLiteralEquals(value, schema.value);
@@ -610,7 +609,7 @@ export function validateValueAgainstSchema(schema: BoundedQuerySchemaNode, value
* semantic value (whitespace, key order, numeric formatting) collapse to the
* identical observable transcript.
*/
-export function canonicalizeSchemaValue(schema: BoundedQuerySchemaNode, value: unknown): string {
+export function canonicalizeSchemaValue(schema: FiniteSchemaNode, value: unknown): string {
switch (schema.type) {
case 'const':
return JSON.stringify(schema.value);
@@ -808,26 +807,26 @@ export function strictParseJson(text: string): { value: unknown } | undefined {
// ── Request/result validation and canonical envelopes ───────────────────────
-/** A bounded-query execution request, already assembled from wire framing. */
-export interface BoundedQueryRequest {
+/** An enclave script execution request, already assembled from MCP arguments. */
+export interface EnclaveScriptRequest {
/** Private repository (`owner/repo`) the query script runs against. */
privateRepo: string;
- /** The agent-authored, AWF-bounded finite response schema. */
- schema: BoundedQuerySchemaNode;
+ /** The caller-authored finite response schema constrained by AWF. */
+ schema: FiniteSchemaNode;
/** The query script source. */
script: string;
}
-export type BoundedQueryValidation =
- | { valid: true; request: BoundedQueryRequest }
+export type EnclaveScriptRequestValidation =
+ | { valid: true; request: EnclaveScriptRequest }
| { valid: false; errors: string[] };
/**
- * Validates an unknown value as a {@link BoundedQueryRequest}: field shape,
+ * Validates an unknown value as a {@link EnclaveScriptRequest}: field shape,
* the `privateRepo` slug pattern, the finite response schema, and the
* script size cap.
*/
-export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidation {
+export function validateEnclaveScriptRequest(raw: unknown): EnclaveScriptRequestValidation {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return { valid: false, errors: ['request must be a JSON object'] };
}
@@ -842,7 +841,7 @@ export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidatio
if (typeof privateRepo !== 'string' || privateRepo.length === 0) {
errors.push('privateRepo must be a non-empty string');
- } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) {
+ } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !PRIVATE_REPOSITORY_PATTERN.test(privateRepo)) {
errors.push(
'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)',
);
@@ -872,10 +871,10 @@ export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidatio
}
/** The canonical JSON text for every failure: `{"status":"error"}`. */
-export const CANONICAL_ERROR_JSON = '{"status":"error"}';
+export const CANONICAL_ERROR_RESPONSE_JSON = '{"status":"error"}';
/** Wraps an already-canonicalized result value into the canonical success envelope. */
-export function canonicalOkJson(canonicalResultJson: string): string {
+export function canonicalSuccessJson(canonicalResultJson: string): string {
return `{"status":"ok","result":${canonicalResultJson}}`;
}
@@ -887,11 +886,11 @@ export function canonicalOkJson(canonicalResultJson: string): string {
* Every failure mode — oversized output, malformed JSON, duplicate keys,
* wrong type, out-of-range value, unknown enum member, missing/extra
* fields, wrong tuple/array length, unknown union tag — maps to the same
- * `{ ok: false }`, which callers turn into {@link CANONICAL_ERROR_JSON}.
+ * `{ ok: false }`, which callers turn into {@link CANONICAL_ERROR_RESPONSE_JSON}.
*/
-export function parseAndValidateQueryOutput(
+export function parseAndValidateFiniteOutput(
raw: string,
- schema: BoundedQuerySchemaNode,
+ schema: FiniteSchemaNode,
): { ok: true; canonical: string } | { ok: false } {
if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false };
const parsed = strictParseJson(raw);
@@ -899,20 +898,3 @@ export function parseAndValidateQueryOutput(
if (!validateValueAgainstSchema(schema, parsed.value)) return { ok: false };
return { ok: true, canonical: canonicalizeSchemaValue(schema, parsed.value) };
}
-
-/**
- * Reusable bounded-execution names. The bounded-query names above remain the
- * compatibility contract; these aliases expose the same implementations and
- * constants to later trusted brokers without creating a second code path.
- */
-export type FiniteSchemaNode = BoundedQuerySchemaNode;
-export type FiniteSchemaValidation = BoundedQuerySchemaValidation;
-export const validateFiniteSchema = validateSchema;
-export const finiteSchemaCardinality = schemaCardinality;
-export const informationChargeForSchema = queryBitsForSchema;
-export const canonicalizeFiniteSchemaValue = canonicalizeSchemaValue;
-export const canonicalSuccessJson = canonicalOkJson;
-export const CANONICAL_ERROR_RESPONSE_JSON = CANONICAL_ERROR_JSON;
-export const PRIVATE_REPOSITORY_PATTERN = BOUNDED_QUERY_REPO_PATTERN;
-export const MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS = MAX_QUERY_TIMEOUT_SECONDS;
-export const parseAndValidateFiniteOutput = parseAndValidateQueryOutput;
diff --git a/src/bounded-execution/repository-staging.ts b/src/bounded-execution/repository-staging.ts
index 23b9fadf8..be2984fa5 100644
--- a/src/bounded-execution/repository-staging.ts
+++ b/src/bounded-execution/repository-staging.ts
@@ -12,14 +12,11 @@ import type { EnclaveSensitivity } from '../types/enclave-options';
* Version of the on-disk seed-map document.
*
* v2 adds trusted `sensitivity` metadata to every entry (see
- * {@link BoundedQuerySeedMap}) so the broker can derive each repository's
+ * {@link PrivateRepositorySeedMap}) so the server can derive each repository's
* per-run information budget without trusting anything the agent sends.
*/
export const PRIVATE_REPOSITORY_SEED_MAP_VERSION = 2;
-/** Bounded-query compatibility constant. */
-export const BOUNDED_QUERY_SEED_MAP_VERSION = PRIVATE_REPOSITORY_SEED_MAP_VERSION;
-
/** One staged, immutable repository seed. */
export interface PrivateRepositorySeedDescriptor {
/** Normalized (lowercased) `owner/repo` lookup key. */
@@ -59,11 +56,6 @@ export interface PrivateRepositoryStagingResult {
seeds: PrivateRepositorySeedDescriptor[];
}
-/** Bounded-query compatibility aliases. */
-export type BoundedQuerySeed = PrivateRepositorySeedDescriptor;
-export type BoundedQuerySeedMap = PrivateRepositorySeedMap;
-export type BoundedQueryStagingResult = PrivateRepositoryStagingResult;
-
/** Canonical lookup key shared by staging, admission, and budget accounting. */
export function normalizePrivateRepositoryKey(repo: string): string {
return repo.trim().toLowerCase();
diff --git a/src/bounded-query/broker.test.ts b/src/bounded-query/broker.test.ts
deleted file mode 100644
index 3b6c44ff2..000000000
--- a/src/bounded-query/broker.test.ts
+++ /dev/null
@@ -1,883 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import { EventEmitter } from 'events';
-
-/**
- * Behavioural tests for the trusted broker (protocol v2), exercised through
- * its real filesystem workspace code with a mocked Docker runner and an
- * injectable clock.
- *
- * These stand in for a full end-to-end query run: they prove the
- * writable-copy semantics, the seed's immutability, repository isolation,
- * the operational invocation budget, the per-repository *bit* ledger (no
- * per-query cap — every invocation's schema-derived charge is computed and
- * debited before launch), the timing-bucket response discipline (via a fake
- * monotonic clock, never real time), workspace teardown, and — most
- * importantly — that every failure path produces the byte-identical
- * canonical `{"status":"error"}` with no extra signal.
- */
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const { createBroker } = require(path.join(brokerDir, 'broker.js'));
-const { createAuditLog } = require(path.join(brokerDir, 'audit.js'));
-const workspace = require(path.join(brokerDir, 'workspace.js'));
-const {
- QUERY_MAX_FILE_BYTES,
- QUERY_WORKSPACE_TMPFS_BYTES,
- buildQueryArgs,
- normalizeTimeoutMs,
-} = require(path.join(brokerDir, 'query-runner.js'));
-const { buildRequestFromFrame, readBoundedBody } = require(path.join(brokerDir, 'framing.js'));
-const { TIMING_BUCKETS_MS } = require(path.join(brokerDir, 'scheduler.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const CANONICAL_ERROR = '{"status":"error"}';
-// A fixed-shape object schema (one enum-valued field) keeps most vectors
-// structurally identical to the old three-outcome protocol while exercising
-// the new schema-carrying request and ok/error envelope.
-const OUTCOME_SCHEMA = { type: 'object', fields: { result: { type: 'enum', values: ['YES', 'NO', 'UNKNOWN'] } } };
-
-it('persists audit records before returning to the caller', () => {
- const auditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-audit-'));
- try {
- const audit = createAuditLog(auditDir);
- audit.failure('invocation-1', 'query-error', 'container failed');
-
- const records = fs.readFileSync(path.join(auditDir, 'bounded-query.jsonl'), 'utf8')
- .trim()
- .split('\n')
- .map((line) => JSON.parse(line));
- expect(records).toEqual([
- expect.objectContaining({
- kind: 'failure',
- invocationId: 'invocation-1',
- reason: 'query-error',
- detail: 'container failed',
- }),
- ]);
- } finally {
- fs.rmSync(auditDir, { recursive: true, force: true });
- }
-});
-
-interface AuditRecord {
- kind: string;
- reason?: string;
- [key: string]: unknown;
-}
-
-function createAudit(): { records: AuditRecord[]; log: Record void> } {
- const records: AuditRecord[] = [];
- return {
- records,
- log: {
- invocation: (record: never) => records.push({ kind: 'invocation', ...(record as object) }),
- failure: ((invocationId: string, reason: string, detail?: string) =>
- records.push({ kind: 'failure', invocationId, reason, detail })) as never,
- lifecycle: ((event: string) => records.push({ kind: 'lifecycle', event })) as never,
- } as unknown as Record void>,
- };
-}
-
-/** A fake monotonic clock the tests fully control — no real time ever elapses. */
-function createFakeClock() {
- let value = 0;
- const sleeps: number[] = [];
- return {
- clock: {
- nowMs: () => value,
- sleep: (ms: number) => {
- sleeps.push(ms);
- value += ms;
- return Promise.resolve();
- },
- },
- advance(ms: number): void {
- value += ms;
- },
- sleeps,
- };
-}
-
-/** Awaits `broker.handle`, capturing the single callback response. */
-async function invoke(
- broker: { handle: (request: unknown, respond: (json: string) => void) => Promise },
- request: unknown,
-): Promise {
- let response = '';
- await broker.handle(request, (json: string) => {
- response = json;
- });
- return response;
-}
-
-describe('bounded-query broker', () => {
- let root: string;
- let config: Record;
- let seedMap: Map;
- const seedIdA = 'a'.repeat(32);
- const seedIdB = 'b'.repeat(32);
-
- function seedPath(seedId: string): string {
- return path.join(String(config.seedsDir), seedId);
- }
-
- /** Creates an immutable seed with the same read-only guarantee as staging. */
- function createSeed(seedId: string, files: Record): void {
- const target = seedPath(seedId);
- fs.mkdirSync(path.join(target, 'src'), { recursive: true });
- for (const [name, contents] of Object.entries(files)) {
- fs.writeFileSync(path.join(target, name), contents);
- }
- const lockdown = (p: string): void => {
- const stat = fs.lstatSync(p);
- if (stat.isDirectory()) {
- for (const entry of fs.readdirSync(p)) lockdown(path.join(p, entry));
- }
- fs.chmodSync(p, stat.mode & ~0o222);
- };
- lockdown(target);
- }
-
- function unlockSeeds(): void {
- const unlock = (p: string): void => {
- if (!fs.existsSync(p)) return;
- const stat = fs.lstatSync(p);
- fs.chmodSync(p, stat.mode | 0o700);
- if (stat.isDirectory()) {
- for (const entry of fs.readdirSync(p)) unlock(path.join(p, entry));
- }
- };
- unlock(String(config.seedsDir));
- }
-
- beforeEach(() => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-broker-test-'));
- config = {
- seedsDir: path.join(root, 'seeds'),
- workDir: path.join(root, 'work'),
- hostWorkDir: '/daemon/work',
- queryMountDir: '/query',
- queryScriptPath: '/awf/query-script.py',
- querySeccompPath: '/opt/awf/query-seccomp.json',
- queryImage: 'ghcr.io/example/bounded-query:1',
- queryBackend: 'docker',
- memoryLimit: '512m',
- timeoutSeconds: 30,
- maxInvocations: 3,
- // The real broker runs as root; tests keep the invoking uid so the
- // ownership transfer is exercised without requiring privileges.
- queryUid: process.getuid?.() ?? 0,
- queryGid: process.getgid?.() ?? 0,
- };
- fs.mkdirSync(String(config.workDir), { recursive: true });
- fs.mkdirSync(String(config.seedsDir), { recursive: true });
- createSeed(seedIdA, { 'README.md': 'repo A secret\n' });
- createSeed(seedIdB, { 'README.md': 'repo B secret\n' });
- seedMap = new Map([
- ['octo/alpha', { seedId: seedIdA, sensitivity: 'internal' }],
- ['octo/beta', { seedId: seedIdB, sensitivity: 'confidential' }],
- ]);
- });
-
- afterEach(() => {
- unlockSeeds();
- fs.rmSync(root, { recursive: true, force: true });
- });
-
- function build(
- runner: { runQueryContainer: (params: never) => Promise },
- opts: {
- workspace?: typeof workspace;
- clock?: { nowMs: () => number; sleep: (ms: number) => Promise };
- seeds?: Map;
- } = {},
- ) {
- const audit = createAudit();
- const broker = createBroker({
- config,
- seedMap: opts.seeds || seedMap,
- runId: 'run-1234abcd',
- audit: audit.log,
- workspace: opts.workspace || workspace,
- runner,
- clock: opts.clock,
- });
- return { broker, audit };
- }
-
- /** Mock runner that behaves like a query script executing inside the sandbox. */
- function queryRunner(behaviour: (invocationDir: string) => void, overrides: Record = {}) {
- const seen: string[] = [];
- return {
- seen,
- runQueryContainer: async ({ invocationId }: { invocationId: string }) => {
- // The invocation root contains the assigned seed copy, output file, and
- // submitted script. The fixed entrypoint copies the read-only seed
- // mount into bounded tmpfs before running the script.
- const invocationDir = path.join(String(config.workDir), invocationId);
- seen.push(invocationDir);
- behaviour(invocationDir);
- return { exitCode: 0, timedOut: false, stdout: '', stderr: '', ...overrides };
- },
- } as unknown as { runQueryContainer: (params: never) => Promise } & { seen: string[] };
- }
-
- const validRequest = (repo = 'octo/alpha') => ({
- privateRepo: repo,
- schema: OUTCOME_SCHEMA,
- script: 'query',
- });
-
- it('returns the canonically re-serialized declared outcome inside the ok envelope', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), ' {"result": "YES"} ');
- });
- const { broker } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe('{"status":"ok","result":{"result":"YES"}}');
- });
-
- it('gives the query a read-only copy of the repo and leaves the seed unchanged', async () => {
- let observed = '';
- const runner = queryRunner((invocationDir) => {
- // Query reads from the repo copy (mounted :ro in Docker).
- observed = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8');
- // Query writes its answer to the pre-created out file.
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"NO"}');
- });
- const { broker } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe('{"status":"ok","result":{"result":"NO"}}');
- expect(observed).toBe('repo A secret\n');
- // The seed itself is untouched.
- expect(fs.readFileSync(path.join(seedPath(seedIdA), 'README.md'), 'utf8')).toBe('repo A secret\n');
- expect(fs.existsSync(path.join(seedPath(seedIdA), 'src'))).toBe(true);
- });
-
- it('destroys the per-invocation copy afterwards', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const { broker } = build(runner);
-
- await invoke(broker, validRequest());
-
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('rejects new invocations after shutdown starts without consuming budget or launching', async () => {
- const runner = queryRunner(() => {
- throw new Error('query must not launch');
- });
- const { broker } = build(runner);
-
- broker.close();
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(broker.invocationsUsed).toBe(0);
- expect(runner.seen).toEqual([]);
- });
-
- it('never exposes another repository or the seed parent to a query', async () => {
- let repoContents = '';
- let siblings: string[] = [];
- const runner = queryRunner((invocationDir) => {
- repoContents = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8');
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- siblings = fs.readdirSync(invocationDir).sort();
- });
- const { broker } = build(runner);
-
- await invoke(broker, validRequest('octo/beta'));
-
- expect(repoContents).toBe('repo B secret\n');
- expect(siblings).toEqual(['out', 'repo', 'script.py']);
- expect(repoContents).not.toContain('repo A');
- });
-
- it('rejects a repository outside the AWF-generated map without launching', async () => {
- const runner = queryRunner(() => {
- throw new Error('query must not launch');
- });
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, validRequest('octo/not-configured'))).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ kind: 'failure', reason: 'repo-not-allowed' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it.each([
- ['extra launch control field', { ...validRequest(), image: 'evil' }],
- ['a smuggled sensitivity override (requests cannot choose sensitivity)', { ...validRequest(), sensitivity: 'public' }],
- ['invalid schema construct', { ...validRequest(), schema: { type: 'nope' } }],
- ['path traversal repo selector', { privateRepo: '../../seeds', schema: OUTCOME_SCHEMA, script: 'x' }],
- ])('rejects %s before copying or launching', async (_name, request) => {
- const runner = queryRunner(() => {
- throw new Error('query must not launch');
- });
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, request)).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'invalid-request' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it.each([
- [
- 'no output file',
- (invocationDir: string): void => {
- fs.unlinkSync(path.join(invocationDir, 'out'));
- },
- 'unreadable-output',
- ],
- [
- 'oversized output',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), 'x'.repeat(8193));
- },
- 'unreadable-output',
- ],
- [
- 'symlinked output',
- (invocationDir: string): void => {
- fs.unlinkSync(path.join(invocationDir, 'out'));
- fs.symlinkSync('/etc/hosts', path.join(invocationDir, 'out'));
- },
- 'unreadable-output',
- ],
- [
- 'undeclared enum value',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"MAYBE"}');
- },
- 'nonconformant-output',
- ],
- [
- 'extra fields',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES","leak":"secret"}');
- },
- 'nonconformant-output',
- ],
- [
- 'duplicate keys',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES","result":"NO"}');
- },
- 'nonconformant-output',
- ],
- [
- 'trailing bytes',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"} leaked');
- },
- 'nonconformant-output',
- ],
- [
- 'invalid UTF-8',
- (invocationDir: string): void => {
- fs.writeFileSync(path.join(invocationDir, 'out'), Buffer.from([0x7b, 0xff, 0xfe, 0x7d]));
- },
- 'unreadable-output',
- ],
- ])('maps %s to the canonical error', async (_name, behaviour, reason) => {
- const runner = queryRunner(behaviour as (invocationDir: string) => void);
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('maps a timeout to the canonical error', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- }, { timedOut: true, exitCode: 137 });
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'timeout' });
- });
-
- it('maps a non-zero query exit to the canonical error even when output is valid', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- }, { exitCode: 2 });
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'non-zero-exit' });
- });
-
- it('includes cleanup before the response and maps cleanup failure to canonical error', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const cleanupFailingWorkspace = {
- ...workspace,
- destroyInvocationWorkspace: () => {
- throw new Error('cleanup failed');
- },
- };
- const { broker, audit } = build(runner, { workspace: cleanupFailingWorkspace });
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'cleanup-failed' });
- });
-
- it('destroys a partial workspace when workspace creation throws', async () => {
- const partialWorkspace = {
- ...workspace,
- createInvocationWorkspace: (params: { config: { workDir: string }; invocationId: string }) => {
- fs.mkdirSync(path.join(params.config.workDir, params.invocationId), { recursive: true });
- fs.writeFileSync(path.join(params.config.workDir, params.invocationId, 'partial'), 'data');
- throw new Error('copy failed');
- },
- };
- const runner = queryRunner(() => {
- throw new Error('query must not launch');
- });
- const { broker, audit } = build(runner, { workspace: partialWorkspace });
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'workspace-create-failed' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('maps a launch failure to the canonical error', async () => {
- const runner = {
- runQueryContainer: async () => {
- throw new Error('daemon unreachable');
- },
- } as unknown as { runQueryContainer: (params: never) => Promise };
- const { broker, audit } = build(runner);
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'launch-failed' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('produces byte-identical responses for every failure-shaped answer', async () => {
- const failures = await Promise.all([
- invoke(build(queryRunner(() => {})).broker, validRequest('octo/nope')),
- invoke(build(queryRunner(() => {})).broker, { privateRepo: 'octo/alpha', schema: { type: 'nope' }, script: 'x' }),
- ]);
-
- expect(new Set(failures)).toEqual(new Set([CANONICAL_ERROR]));
- });
-
- it('enforces the per-run invocation budget atomically and without launching', async () => {
- const launches: string[] = [];
- const runner = {
- runQueryContainer: async ({ invocationId }: { invocationId: string }) => {
- launches.push(invocationId);
- const invocationDir = path.join(String(config.workDir), invocationId);
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- return { exitCode: 0, timedOut: false };
- },
- } as unknown as { runQueryContainer: (params: never) => Promise };
- const { broker, audit } = build(runner);
-
- const results = await Promise.all(Array.from({ length: 5 }, () => invoke(broker, validRequest())));
-
- expect(results.filter((r) => r === '{"status":"ok","result":{"result":"YES"}}')).toHaveLength(3);
- expect(results.filter((r) => r === CANONICAL_ERROR)).toHaveLength(2);
- expect(launches).toHaveLength(3);
- expect(audit.records.filter((r) => r.reason === 'invocation-count-exhausted')).toHaveLength(2);
- });
-
- it('records failure reasons only in the protected audit log, never in the response', async () => {
- const runner = queryRunner((invocationDir) => {
- fs.unlinkSync(path.join(invocationDir, 'out'));
- });
- const { broker, audit } = build(runner);
-
- const response = await invoke(broker, validRequest());
-
- expect(response).toBe(CANONICAL_ERROR);
- expect(JSON.stringify(audit.records)).toContain('unreadable-output');
- });
-
- it('preserves relative repository symlinks verbatim in the writable copy', () => {
- const seed = seedPath(seedIdA);
- fs.chmodSync(seed, 0o700);
- fs.symlinkSync('README.md', path.join(seed, 'README-link'));
- fs.chmodSync(seed, 0o500);
-
- const layout = workspace.createInvocationWorkspace({
- config,
- invocationId: 'symlink-test',
- seedId: seedIdA,
- script: 'pass',
- });
-
- expect(fs.readlinkSync(path.join(layout.repoDir, 'README-link'))).toBe('README.md');
- });
-
- describe('per-repository bit ledger (no per-query cap)', () => {
- it("debits an invocation's exact schema charge before copying a seed or launching Python", async () => {
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const { broker } = build(runner);
-
- // 1 (status) + 2 (ceil(log2(3)) for the 3-valued enum) + 3 (timing) = 6 bits.
- const before = broker.ledger.remainingBits('octo/alpha');
- await invoke(broker, validRequest());
- expect(broker.ledger.remainingBits('octo/alpha')).toBe(before - 6);
- });
-
- it('never debits the ledger for a request rejected before validation succeeds', async () => {
- const runner = queryRunner(() => {
- throw new Error('query must not launch');
- });
- const { broker } = build(runner);
-
- const before = broker.ledger.remainingBits('octo/alpha');
- await invoke(broker, { ...validRequest(), schema: { type: 'nope' } });
- expect(broker.ledger.remainingBits('octo/alpha')).toBe(before);
- });
-
- it('denies (without launching) an invocation whose schema charge exceeds the remaining balance', async () => {
- const runner = queryRunner(() => {
- throw new Error('query must not launch: charge exceeds confidential (8-bit) budget');
- });
- const { broker, audit } = build(runner);
-
- // A 256-value enum costs 1 + 8 + 3 = 12 bits — more than octo/beta's
- // 8-bit "confidential" run budget.
- const expensiveSchema = { type: 'enum', values: Array.from({ length: 256 }, (_, i) => i) };
- const response = await invoke(broker, { privateRepo: 'octo/beta', schema: expensiveSchema, script: 'x' });
-
- expect(response).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'bit-budget-exhausted' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('a sealed-sensitivity repository (0-bit run budget) can never afford even the cheapest schema', async () => {
- const zeroBudgetSeedMap = new Map([['octo/sealed', { seedId: seedIdA, sensitivity: 'sealed' }]]);
- const runner = queryRunner(() => {
- throw new Error('a sealed repo must never launch a query');
- });
- const { broker, audit } = build(runner, { seeds: zeroBudgetSeedMap });
-
- // The cheapest possible schema (const) still costs 1 + 0 + 3 = 4 bits > 0.
- const response = await invoke(broker, {
- privateRepo: 'octo/sealed',
- schema: { type: 'const', value: 'x' },
- script: 'x',
- });
-
- expect(response).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'bit-budget-exhausted' });
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('a public-sensitivity repository is unmetered and never runs out of budget', async () => {
- const publicSeedMap = new Map([['octo/public', { seedId: seedIdA, sensitivity: 'public' }]]);
- config.maxInvocations = 20;
- const runner = queryRunner((invocationDir) => {
- fs.writeFileSync(path.join(invocationDir, 'out'), '[0,0,0,0,0,0,0,0]');
- });
- const { broker } = build(runner, { seeds: publicSeedMap });
-
- // A tuple of eight 16-bit integers costs 1 + 128 + 3 = 132 bits — far
- // beyond even "internal"'s 64-bit run budget, many times over. Only
- // "public" (unmetered, `null` in the ledger) could ever afford it more
- // than zero times.
- const bigSchema = {
- type: 'tuple',
- items: Array.from({ length: 8 }, () => ({ type: 'integer', minimum: 0, maximum: 65535 })),
- };
- for (let i = 0; i < 10; i++) {
- // eslint-disable-next-line no-await-in-loop
- expect(await invoke(broker, { privateRepo: 'octo/public', schema: bigSchema, script: 'x' })).not.toBe(
- CANONICAL_ERROR,
- );
- }
- expect(broker.ledger.remainingBits('octo/public')).toBeNull();
- });
- });
-
- describe('response-timing bucketing (fake monotonic clock — no real time elapses)', () => {
- it('buckets a fast-completing invocation to the smallest boundary at or after elapsed processing time', async () => {
- const { clock, advance, sleeps } = createFakeClock();
- const runner = queryRunner((invocationDir) => {
- advance(50); // Simulate 50ms of processing — falls in the 100ms bucket.
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const { broker, audit } = build(runner, { clock });
-
- await invoke(broker, validRequest());
-
- const invocationRecord = audit.records.find((r) => r.kind === 'invocation');
- expect(invocationRecord).toMatchObject({ bucketMs: 100 });
- // Waited the remaining 50ms to reach the 100ms boundary.
- expect(sleeps).toEqual([50]);
- });
-
- it('does not wait at all when processing already lands exactly on a bucket boundary', async () => {
- const { clock, advance, sleeps } = createFakeClock();
- const runner = queryRunner((invocationDir) => {
- advance(10); // Exactly the smallest bucket.
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const { broker, audit } = build(runner, { clock });
-
- await invoke(broker, validRequest());
-
- expect(audit.records.find((r) => r.kind === 'invocation')).toMatchObject({ bucketMs: 10 });
- expect(sleeps).toEqual([]);
- });
-
- it('buckets a failure response exactly like a success response', async () => {
- const { clock, advance } = createFakeClock();
- const runner = queryRunner((invocationDir) => {
- advance(500); // Falls in the 1000ms bucket.
- fs.writeFileSync(path.join(invocationDir, 'out'), 'not valid json');
- });
- const { broker, audit } = build(runner, { clock });
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- const failureRecord = [...audit.records].reverse().find((r) => r.kind === 'failure');
- // Failure records don't currently carry bucketMs (only invocation
- // records do), but the wait itself must still have occurred — this is
- // implicitly proven by the overflow test below reaching a different
- // code path only when elapsed exceeds every bucket.
- expect(failureRecord).toMatchObject({ reason: 'nonconformant-output' });
- });
-
- it('includes workspace cleanup latency when selecting the timing bucket', async () => {
- const { clock, advance, sleeps } = createFakeClock();
- const runner = queryRunner((invocationDir) => {
- advance(5);
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const cleanupWorkspace = {
- ...workspace,
- destroyInvocationWorkspace: (workDir: string, invocationId: string) => {
- advance(50);
- workspace.destroyInvocationWorkspace(workDir, invocationId);
- },
- };
- const { broker, audit } = build(runner, { clock, workspace: cleanupWorkspace });
-
- await invoke(broker, validRequest());
-
- expect(audit.records.find((r) => r.kind === 'invocation')).toMatchObject({ bucketMs: 100 });
- expect(sleeps).toEqual([45]);
- });
-
- it('fails closed with the canonical error when processing overruns every configured bucket, even for an otherwise-valid result', async () => {
- const { clock, advance } = createFakeClock();
- const runner = queryRunner((invocationDir) => {
- // Pathological infrastructure latency far beyond the largest bucket
- // (600_000ms) — never possible from the script itself, which is
- // capped at boundedQueries.timeout <= 540s by preflight.ts.
- advance(TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] + 1);
- fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}');
- });
- const { broker, audit } = build(runner, { clock });
-
- expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR);
- expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'timing-bucket-overflow' });
- });
- });
-});
-
-describe('query container arguments', () => {
- const config = {
- hostWorkDir: '/daemon/work',
- queryMountDir: '/query',
- queryScriptPath: '/awf/query-script.py',
- querySeccompPath: '/opt/awf/query-seccomp.json',
- queryImage: 'ghcr.io/example/bounded-query:1',
- queryBackend: 'docker',
- memoryLimit: '256m',
- queryUid: 65534,
- queryGid: 65534,
- };
-
- function args(overrides: Record = {}, runtimeName?: string): string[] {
- return buildQueryArgs({
- config: { ...config, ...overrides },
- runId: 'run-1',
- invocationId: 'inv-1',
- runtimeName,
- });
- }
-
- it('isolates the query: no network, read-only rootfs, non-root, no capabilities', () => {
- const joined = args().join(' ');
- expect(joined).toContain('--network none');
- expect(joined).toContain('--read-only');
- expect(joined).toContain('--user 65534:65534');
- expect(joined).toContain('--cap-drop ALL');
- expect(joined).toContain('--security-opt no-new-privileges:true');
- expect(joined).toContain('--security-opt seccomp=/opt/awf/query-seccomp.json');
- });
-
- it('bounds memory, CPU, PIDs, file size, and descriptors', () => {
- const joined = args().join(' ');
- expect(joined).toContain('--memory 256m');
- expect(joined).toContain('--memory-swap 256m');
- expect(joined).toContain('--cpus 1');
- expect(joined).toContain('--pids-limit 128');
- expect(joined).toContain(`--ulimit fsize=${512 * 1024 * 1024}`);
- expect(joined).toContain('--ulimit nofile=1024:1024');
- });
-
- it('mounts only the invocation workspace and the fixed read-only script path', () => {
- const mounts = args().reduce((acc, value, index, all) => {
- if (value === '-v') acc.push(all[index + 1]);
- return acc;
- }, []);
-
- expect(mounts).toEqual([
- '/daemon/work/inv-1/repo:/awf/seed:ro',
- '/daemon/work/inv-1/out:/query/out:rw',
- '/daemon/work/inv-1/script.py:/awf/query-script.py:ro',
- ]);
- });
-
- it('backs /query with a size-limited tmpfs for aggregate storage enforcement', () => {
- const joined = args().join(' ');
- expect(joined).toContain(
- `--tmpfs /query:rw,nosuid,nodev,size=${1024 * 1024 * 1024},uid=65534,gid=65534,mode=0700`,
- );
- expect(joined).not.toContain(':/query:rw');
- expect(joined).not.toContain(':/query/repo:rw');
- });
-
- it('fits the bounded github/gh-aw smoke-test seed', () => {
- expect(QUERY_MAX_FILE_BYTES).toBe(512 * 1024 * 1024);
- expect(QUERY_WORKSPACE_TMPFS_BYTES).toBe(1024 * 1024 * 1024);
- });
-
- it('never mounts the Docker socket, the seeds root, or a workspace', () => {
- const joined = args().join(' ');
- expect(joined).toContain('--pull never');
- expect(joined).not.toContain('docker.sock');
- expect(joined).not.toContain('/srv/awf/seeds');
- expect(joined).not.toContain('/host');
- });
-
- it('runs the fixed entrypoint that materializes the writable repo before the script', () => {
- const argv = args();
- expect(argv).toContain('--entrypoint');
- expect(argv[argv.indexOf('--entrypoint') + 1]).toBe('/usr/local/bin/run-query');
- expect(argv[argv.length - 1]).toBe('ghcr.io/example/bounded-query:1');
- expect(argv).not.toContain('-I');
- });
-
- it('labels the container for orphan cleanup', () => {
- expect(args().join(' ')).toContain('--label awf.bounded-query.run=run-1');
- });
-
- it('passes an explicit OCI runtime only when selected by the trusted runner', () => {
- expect(args().includes('--runtime')).toBe(false);
- expect(args({}, 'runsc').join(' ')).toContain('--runtime runsc');
- });
-
- it('normalizes fractional monotonic durations for Node child-process timeouts', () => {
- expect(normalizeTimeoutMs(31_872.77068800002)).toBe(31_873);
- });
-});
-
-describe('request framing (protocol v2)', () => {
- function base64url(text: string): string {
- return Buffer.from(text, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
- }
-
- const schema = { type: 'boolean' };
- const headers = {
- 'x-awf-query-version': '2',
- 'x-awf-repo': 'octo/alpha',
- 'x-awf-schema-b64': base64url(JSON.stringify(schema)),
- };
- const rawHeaders = Object.entries(headers).flat();
-
- it('assembles the canonical request object, decoding the schema header', () => {
- expect(buildRequestFromFrame(headers, rawHeaders, 'print(1)')).toEqual({
- request: { privateRepo: 'octo/alpha', schema, script: 'print(1)' },
- });
- });
-
- it('rejects an unsupported protocol version', () => {
- expect(buildRequestFromFrame({ ...headers, 'x-awf-query-version': '1' }, rawHeaders, 'x').error)
- .toMatch(/protocol version/);
- });
-
- it('rejects any additional x-awf control header', () => {
- const withExtra = [...rawHeaders, 'X-AWF-Timeout', '9999'];
- expect(buildRequestFromFrame(headers, withExtra, 'x').error).toMatch(/unsupported request control header/);
- });
-
- it('rejects duplicated headers so the repo or schema cannot be smuggled', () => {
- const duplicated = [...rawHeaders, 'X-AWF-Repo', 'octo/sneaky'];
- expect(buildRequestFromFrame(headers, duplicated, 'x').error).toMatch(/duplicate request header/);
- });
-
- function omit(name: string): Record {
- return Object.fromEntries(Object.entries(headers).filter(([key]) => key !== name));
- }
-
- it('rejects a missing schema header', () => {
- expect(buildRequestFromFrame(omit('x-awf-schema-b64'), rawHeaders, 'x').error)
- .toMatch(/missing or malformed schema header/);
- });
-
- it('rejects a missing repository selector', () => {
- expect(buildRequestFromFrame(omit('x-awf-repo'), rawHeaders, 'x').error)
- .toMatch(/missing repository selector/);
- });
-
- it('rejects a schema header that is not valid base64url', () => {
- expect(buildRequestFromFrame({ ...headers, 'x-awf-schema-b64': 'not base64url!!' }, rawHeaders, 'x').error)
- .toMatch(/missing or malformed schema header/);
- });
-
- it('rejects a schema header that decodes to invalid JSON', () => {
- const badSchema = base64url('not json at all');
- expect(buildRequestFromFrame({ ...headers, 'x-awf-schema-b64': badSchema }, rawHeaders, 'x').error)
- .toMatch(/not valid JSON/);
- });
-
- it('rejects a schema header that decodes to invalid UTF-8', () => {
- const invalidUtf8 = Buffer.from([0xff, 0xfe]).toString('base64url');
- expect(buildRequestFromFrame({ ...headers, 'x-awf-schema-b64': invalidUtf8 }, rawHeaders, 'x').error)
- .toMatch(/missing or malformed schema header/);
- });
-});
-
-describe('bounded request body reading', () => {
- function fakeRequest(chunks: (Buffer | string)[]): EventEmitter & { pause: () => void } {
- const emitter = new EventEmitter() as EventEmitter & { pause: () => void };
- emitter.pause = jest.fn();
- process.nextTick(() => {
- for (const chunk of chunks) emitter.emit('data', Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
- emitter.emit('end');
- });
- return emitter;
- }
-
- it('reads a well-formed script body', async () => {
- const req = fakeRequest(['print', '(1)']);
- await expect(readBoundedBody(req)).resolves.toEqual({ script: 'print(1)' });
- });
-
- it('rejects a body exceeding the script size cap while streaming', async () => {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { MAX_SCRIPT_BYTES } = require(path.join(brokerDir, 'protocol.js'));
- const req = fakeRequest(['x'.repeat(MAX_SCRIPT_BYTES + 1)]);
- const result = await readBoundedBody(req);
- expect(result).toEqual({ error: 'script exceeds maximum size' });
- });
-
- it('rejects a body that is not valid UTF-8', async () => {
- const req = fakeRequest([Buffer.from([0xff, 0xfe])]);
- await expect(readBoundedBody(req)).resolves.toEqual({ error: 'script is not valid UTF-8' });
- });
-});
diff --git a/src/bounded-query/end-to-end.test.ts b/src/bounded-query/end-to-end.test.ts
deleted file mode 100644
index 3981b37f2..000000000
--- a/src/bounded-query/end-to-end.test.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import { spawn } from 'child_process';
-import * as http from 'http';
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import type { Server } from 'http';
-
-/**
- * End-to-end exercise of the whole agent-visible path:
- *
- * real `bounded-query` wrapper → real Unix socket → real broker server →
- * real workspace/seed handling → (mocked) query container.
- *
- * Only the Docker launch is mocked, so this covers the v2 framing (repo +
- * base64url schema header), the finite schema DSL, the writable-copy
- * semantics, repository isolation, the per-repository sensitivity/bit
- * ledger (no per-query cap), the operational invocation budget, and the
- * uniform failure closure — without needing a Docker daemon or a real
- * private repository.
- */
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const { createBroker } = require(path.join(brokerDir, 'broker.js'));
-const { createServer, listenOnSocket } = require(path.join(brokerDir, 'server.js'));
-const workspace = require(path.join(brokerDir, 'workspace.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const WRAPPER = path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-query-wrapper.sh');
-const CANONICAL_ERROR = '{"status":"error"}';
-const OUTCOME_SCHEMA = JSON.stringify({ type: 'enum', values: ['YES', 'NO'] });
-
-interface WrapperResult {
- stdout: string;
- stderr: string;
- status: number | null;
-}
-
-type AdmissionAwareServer = Server & {
- freezeAdmissions: () => void;
- drainAdmissions: () => Promise;
-};
-
-function runWrapper(socketPath: string, args: string[], script = 'query'): Promise {
- return new Promise((resolve, reject) => {
- const child = spawn('sh', [WRAPPER, ...args], {
- env: { PATH: process.env.PATH ?? '/usr/bin:/bin', AWF_BOUNDED_QUERY_SOCKET: socketPath },
- });
- let stdout = '';
- let stderr = '';
- child.stdout.setEncoding('utf8');
- child.stderr.setEncoding('utf8');
- child.stdout.on('data', (chunk: string) => { stdout += chunk; });
- child.stderr.on('data', (chunk: string) => { stderr += chunk; });
- child.on('error', reject);
- child.on('close', (status) => resolve({ stdout, stderr, status }));
- child.stdin.on('error', () => { /* wrapper may exit before reading stdin */ });
- child.stdin.end(script);
- });
-}
-
-describe('bounded query end-to-end (wrapper → socket → broker)', () => {
- let root: string;
- let server: AdmissionAwareServer;
- let socketPath: string;
- let config: Record;
- const seedIdA = 'a'.repeat(32);
- const seedIdB = 'b'.repeat(32);
- const audit: Array> = [];
-
- /** The mocked query: reads the repo copy and writes a declared outcome. */
- const runner = {
- runQueryContainer: async ({ invocationId }: { invocationId: string }) => {
- const invocationDir = path.join(String(config.workDir), invocationId);
- const readme = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8');
- // Write the answer to the pre-created output file, conforming to the enum schema above.
- fs.writeFileSync(path.join(invocationDir, 'out'), JSON.stringify(readme.includes('alpha') ? 'YES' : 'NO'));
- return { exitCode: 0, timedOut: false };
- },
- };
-
- function lockdown(target: string): void {
- const stat = fs.lstatSync(target);
- if (stat.isDirectory()) {
- for (const entry of fs.readdirSync(target)) lockdown(path.join(target, entry));
- }
- fs.chmodSync(target, stat.mode & ~0o222);
- }
-
- function unlock(target: string): void {
- if (!fs.existsSync(target)) return;
- const stat = fs.lstatSync(target);
- fs.chmodSync(target, stat.mode | 0o700);
- if (stat.isDirectory()) {
- for (const entry of fs.readdirSync(target)) unlock(path.join(target, entry));
- }
- }
-
- beforeEach(async () => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awfe2e-'));
- socketPath = path.join(root, 'b.sock');
- config = {
- seedsDir: path.join(root, 'seeds'),
- workDir: path.join(root, 'work'),
- hostWorkDir: '/daemon/work',
- socketDir: root,
- socketPath,
- queryMountDir: '/query',
- queryScriptPath: '/awf/query-script.py',
- querySeccompPath: '/opt/awf/query-seccomp.json',
- queryImage: 'bounded-query:test',
- queryBackend: 'docker',
- memoryLimit: '512m',
- timeoutSeconds: 30,
- maxInvocations: 2,
- queryUid: process.getuid?.() ?? 0,
- queryGid: process.getgid?.() ?? 0,
- socketUid: process.getuid?.() ?? 0,
- socketGid: process.getgid?.() ?? 0,
- };
-
- fs.mkdirSync(String(config.workDir), { recursive: true });
- for (const [seedId, marker] of [[seedIdA, 'alpha'], [seedIdB, 'beta']]) {
- const dir = path.join(String(config.seedsDir), seedId);
- fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(path.join(dir, 'README.md'), `${marker} private contents\n`);
- lockdown(dir);
- }
-
- audit.length = 0;
- const auditLog = {
- invocation: (record: Record) => audit.push({ kind: 'invocation', ...record }),
- failure: (invocationId: string, reason: string) => audit.push({ kind: 'failure', invocationId, reason }),
- lifecycle: () => { /* not asserted */ },
- };
-
- const broker = createBroker({
- config,
- seedMap: new Map([
- ['octo/alpha', { seedId: seedIdA, sensitivity: 'internal' }],
- ['octo/beta', { seedId: seedIdB, sensitivity: 'confidential' }],
- ]),
- runId: 'e2e-run',
- audit: auditLog,
- workspace,
- runner,
- });
-
- server = createServer({ broker, audit: auditLog }) as AdmissionAwareServer;
- await listenOnSocket(server, config, auditLog);
- });
-
- afterEach(async () => {
- await new Promise((resolve) => server.close(() => resolve()));
- unlock(String(config.seedsDir));
- fs.rmSync(root, { recursive: true, force: true });
- });
-
- const args = (repo: string, schema = OUTCOME_SCHEMA) => ['--repo', repo, '--schema', schema];
-
- it('returns the outcome the query computed from its own repository copy', async () => {
- const result = await runWrapper(socketPath, args('octo/alpha'));
-
- expect(result.stdout).toBe('{"status":"ok","result":"YES"}\n');
- expect(result.stderr).toBe('');
- expect(result.status).toBe(0);
- });
-
- it('gives each repository its own contents and never the other one', async () => {
- expect((await runWrapper(socketPath, args('octo/beta'))).stdout).toBe('{"status":"ok","result":"NO"}\n');
- });
-
- it('leaves the immutable seed untouched after the query mutates its copy', async () => {
- await runWrapper(socketPath, args('octo/alpha'));
-
- expect(fs.readFileSync(path.join(String(config.seedsDir), seedIdA, 'README.md'), 'utf8'))
- .toBe('alpha private contents\n');
- expect(fs.readdirSync(String(config.workDir))).toEqual([]);
- });
-
- it('rejects a repository the client asks for but AWF never configured', async () => {
- const result = await runWrapper(socketPath, args('octo/not-configured'));
-
- expect(result.stdout).toBe(`${CANONICAL_ERROR}\n`);
- expect(result.stderr).toBe('');
- expect(result.status).toBe(0);
- expect(audit.some((record) => record.reason === 'repo-not-allowed')).toBe(true);
- });
-
- it('enforces the operational invocation budget across the socket, independent of the bit budget', async () => {
- const first = await runWrapper(socketPath, args('octo/alpha'));
- const second = await runWrapper(socketPath, args('octo/alpha'));
- const third = await runWrapper(socketPath, args('octo/alpha'));
-
- expect(first.stdout).toBe('{"status":"ok","result":"YES"}\n');
- expect(second.stdout).toBe('{"status":"ok","result":"YES"}\n');
- expect(third.stdout).toBe(`${CANONICAL_ERROR}\n`);
- expect(third.stderr).toBe('');
- expect(third.status).toBe(0);
- });
-
- it('enforces the confidential (8-bit) per-repository run budget across the socket', async () => {
- // octo/beta is "confidential" (8 bits/run). A 2-value enum costs
- // 1 + 1 + 3 = 5 bits, so two invocations (10 bits) exceed the budget —
- // the second must be denied even though maxInvocations (2) alone would
- // still allow it.
- const first = await runWrapper(socketPath, args('octo/beta'));
- const second = await runWrapper(socketPath, args('octo/beta'));
-
- expect(first.stdout).toBe('{"status":"ok","result":"NO"}\n');
- expect(second.stdout).toBe(`${CANONICAL_ERROR}\n`);
- expect(audit.some((record) => record.reason === 'bit-budget-exhausted')).toBe(true);
- });
-
- it('rejects an oversized script and charges it against maxInvocations', async () => {
- const result = await runWrapper(socketPath, args('octo/alpha'), 'x'.repeat(64 * 1024 + 10));
- const admitted = await runWrapper(socketPath, args('octo/alpha'));
- const exhausted = await runWrapper(socketPath, args('octo/alpha'));
-
- expect(result.stdout).toBe(`${CANONICAL_ERROR}\n`);
- expect(result.stderr).toBe('');
- expect(result.status).toBe(0);
- expect(admitted.stdout).toBe('{"status":"ok","result":"YES"}\n');
- expect(exhausted.stdout).toBe(`${CANONICAL_ERROR}\n`);
- });
-
- it('rejects a request whose query output does not conform to its own declared schema', async () => {
- const nonConformingRunner = {
- runQueryContainer: async ({ invocationId }: { invocationId: string }) => {
- const invocationDir = path.join(String(config.workDir), invocationId);
- fs.writeFileSync(path.join(invocationDir, 'out'), '"MAYBE"'); // not in the declared enum
- return { exitCode: 0, timedOut: false };
- },
- };
- const auditLog = {
- invocation: () => { /* not asserted */ },
- failure: (invocationId: string, reason: string) => audit.push({ kind: 'failure', invocationId, reason }),
- lifecycle: () => { /* not asserted */ },
- };
- const broker = createBroker({
- config,
- seedMap: new Map([['octo/alpha', { seedId: seedIdA, sensitivity: 'internal' }]]),
- runId: 'e2e-run-2',
- audit: auditLog,
- workspace,
- runner: nonConformingRunner,
- });
- await new Promise((resolve) => server.close(() => resolve()));
- server = createServer({ broker, audit: auditLog }) as AdmissionAwareServer;
- await listenOnSocket(server, config, auditLog);
-
- const result = await runWrapper(socketPath, args('octo/alpha'));
-
- expect(result.stdout).toBe(`${CANONICAL_ERROR}\n`);
- expect(result.status).toBe(0);
- });
-
- it('does not admit a partially read request after shutdown freezes admissions', async () => {
- const handle = jest.fn(async () => {
- throw new Error('broker.handle must not run after shutdown admission freeze');
- });
- const auditLog = {
- invocation: () => { /* not asserted */ },
- failure: () => { /* not asserted */ },
- lifecycle: () => { /* not asserted */ },
- };
- const broker = { handle, drain: async () => undefined, close: () => undefined };
- await new Promise((resolve) => server.close(() => resolve()));
- server = createServer({ broker, audit: auditLog }) as AdmissionAwareServer;
- await listenOnSocket(server, config, auditLog);
-
- const schemaB64 = Buffer.from(OUTCOME_SCHEMA, 'utf8').toString('base64url');
- const requestSeen = new Promise((resolve) => {
- server.once('request', () => resolve());
- });
- const response = new Promise((resolve, reject) => {
- const req = http.request({
- socketPath,
- path: '/query',
- method: 'POST',
- headers: {
- 'content-type': 'text/plain; charset=utf-8',
- 'x-awf-query-version': '2',
- 'x-awf-repo': 'octo/alpha',
- 'x-awf-schema-b64': schemaB64,
- },
- }, (res) => {
- let body = '';
- res.setEncoding('utf8');
- res.on('data', (chunk) => { body += chunk; });
- res.on('end', () => resolve(body));
- });
- req.on('error', reject);
- req.write('partial');
- void requestSeen.then(() => {
- server.freezeAdmissions();
- server.close();
- req.end('-body');
- });
- });
-
- await expect(response).resolves.toBe(CANONICAL_ERROR);
- await server.drainAdmissions();
- expect(handle).not.toHaveBeenCalled();
- });
-});
diff --git a/src/bounded-query/framing-deadline.test.ts b/src/bounded-query/framing-deadline.test.ts
deleted file mode 100644
index 011a7cc7a..000000000
--- a/src/bounded-query/framing-deadline.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { EventEmitter } from 'events';
-import * as path from 'path';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const { BODY_READ_TIMEOUT_MS, readBoundedBody } = require(
- path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'framing.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-describe('bounded-query body framing deadline', () => {
- afterEach(() => {
- jest.useRealTimers();
- });
-
- it('terminates a peer that stops sending its request body', async () => {
- jest.useFakeTimers();
- const request = Object.assign(new EventEmitter(), { pause: jest.fn() });
-
- const result = readBoundedBody(request);
- jest.advanceTimersByTime(BODY_READ_TIMEOUT_MS);
-
- await expect(result).resolves.toEqual({ error: 'request body deadline exceeded' });
- expect(request.pause).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/src/bounded-query/ingress-conformance.test.ts b/src/bounded-query/ingress-conformance.test.ts
deleted file mode 100644
index 2ebd5e4c3..000000000
--- a/src/bounded-query/ingress-conformance.test.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-import * as fs from 'fs';
-import * as http from 'http';
-import * as net from 'net';
-import * as os from 'os';
-import * as path from 'path';
-import type { AddressInfo } from 'net';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const { createServer, createTcpServer, listenOnSocket, listenOnTcp, MAX_CONNECTIONS } = require(
- path.join(brokerDir, 'server.js'),
-);
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const CAPABILITY = 'a'.repeat(64);
-const PROBE_CAPABILITY = 'b'.repeat(64);
-const CANONICAL_ERROR = '{"status":"error"}';
-const CANONICAL_OK = '{"status":"ok","result":true}';
-const SCHEMA = Buffer.from('{"type":"boolean"}').toString('base64url');
-
-interface Response {
- status: number | undefined;
- headers: http.IncomingHttpHeaders;
- body: string;
-}
-
-function stableResponse(response: Response) {
- return {
- status: response.status,
- body: response.body,
- contentType: response.headers['content-type'],
- cacheControl: response.headers['cache-control'],
- contentLength: response.headers['content-length'],
- };
-}
-
-function request(options: http.RequestOptions, body = 'print(True)'): Promise {
- return new Promise((resolve, reject) => {
- const req = http.request({
- method: 'POST',
- path: '/query',
- ...options,
- headers: {
- 'content-type': 'application/octet-stream',
- 'x-awf-query-version': '2',
- 'x-awf-repo': 'octo/private',
- 'x-awf-schema-b64': SCHEMA,
- ...options.headers,
- },
- }, (res) => {
- const chunks: Buffer[] = [];
- res.on('data', (chunk) => chunks.push(chunk));
- res.on('end', () => resolve({
- status: res.statusCode,
- headers: res.headers,
- body: Buffer.concat(chunks).toString('utf8'),
- }));
- });
- req.on('error', reject);
- req.end(body);
- });
-}
-
-describe('bounded-query ingress conformance', () => {
- let root: string;
- let unixServer: http.Server;
- let tcpServer: http.Server;
- let socketPath: string;
- let tcpPort: number;
- let handled: unknown[];
- const audit = {
- failure: jest.fn(),
- lifecycle: jest.fn(),
- };
-
- beforeEach(async () => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-ingress-test-'));
- socketPath = path.join(root, 'broker.sock');
- handled = [];
- const broker = {
- handle: (incoming: unknown, respond: (body: string) => void) => {
- handled.push(incoming);
- respond(incoming === undefined ? CANONICAL_ERROR : CANONICAL_OK);
- return Promise.resolve();
- },
- };
- unixServer = createServer({ broker, audit });
- tcpServer = createTcpServer({
- broker,
- audit,
- capabilities: { query: CAPABILITY, probe: PROBE_CAPABILITY },
- });
- await listenOnSocket(unixServer, {
- socketPath,
- socketDir: root,
- socketUid: process.getuid?.() ?? 0,
- socketGid: process.getgid?.() ?? 0,
- }, audit);
- await listenOnTcp(tcpServer, { tcpPort: 0 });
- tcpPort = (tcpServer.address() as AddressInfo).port;
- });
-
- afterEach(async () => {
- await Promise.all([
- new Promise((resolve) => unixServer.close(() => resolve())),
- new Promise((resolve) => tcpServer.close(() => resolve())),
- ]);
- fs.rmSync(root, { recursive: true, force: true });
- jest.clearAllMocks();
- });
-
- const unixRequest = (body?: string) => request({ socketPath }, body);
- const tcpRequest = (body?: string, capability = CAPABILITY) => request({
- host: '127.0.0.1',
- port: tcpPort,
- headers: { 'x-awf-capability': capability },
- }, body);
-
- it('returns byte-identical status, headers, and canonical result bytes', async () => {
- const [unix, tcp] = await Promise.all([unixRequest(), tcpRequest()]);
- expect(stableResponse(tcp)).toEqual(stableResponse(unix));
- expect(stableResponse(tcp)).toEqual(expect.objectContaining({
- status: 200,
- body: CANONICAL_OK,
- contentType: 'application/json',
- cacheControl: 'no-store',
- contentLength: String(Buffer.byteLength(CANONICAL_OK)),
- }));
- expect(handled).toHaveLength(2);
- expect(handled[0]).toEqual(handled[1]);
- expect(handled[0]).not.toHaveProperty('capability');
- });
-
- it('collapses missing, wrong, and duplicated authentication to canonical failure bytes', async () => {
- const missing = request({ host: '127.0.0.1', port: tcpPort });
- const wrong = tcpRequest(undefined, 'c'.repeat(64));
- const duplicated = request({
- host: '127.0.0.1',
- port: tcpPort,
- headers: { 'x-awf-capability': [CAPABILITY, CAPABILITY] },
- });
- const responses = await Promise.all([missing, wrong, duplicated]);
- for (const response of responses) {
- expect(response.status).toBe(200);
- expect(response.body).toBe(CANONICAL_ERROR);
- }
- expect(handled).toHaveLength(0);
- });
-
- it('uses a one-shot probe capability without launching or consuming a query request', async () => {
- const before = handled.length;
- const first = await tcpRequest('', PROBE_CAPABILITY);
- const second = await tcpRequest('', PROBE_CAPABILITY);
- expect(first.body).toBe(CANONICAL_ERROR);
- expect(second.body).toBe(CANONICAL_ERROR);
- expect(handled.length).toBe(before);
- expect(audit.lifecycle).toHaveBeenCalledWith('sbx-ingress-probe');
- });
-
- it('keeps oversized and parallel request behavior identical across transports', async () => {
- const oversized = 'x'.repeat(64 * 1024 + 1);
- const [unixOversized, tcpOversized] = await Promise.all([
- unixRequest(oversized),
- tcpRequest(oversized),
- ]);
- expect(unixOversized.body).toBe(CANONICAL_ERROR);
- expect(stableResponse(tcpOversized)).toEqual(stableResponse(unixOversized));
-
- const results = await Promise.all([
- unixRequest(),
- unixRequest(),
- tcpRequest(),
- tcpRequest(),
- ]);
- expect(results.map((result) => result.body)).toEqual(Array(4).fill(CANONICAL_OK));
- });
-
- it('does not dispatch broker work for a request that arrives on an over-limit socket', async () => {
- const holders = await Promise.all(Array.from({ length: MAX_CONNECTIONS }, () => new Promise((resolve, reject) => {
- const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => resolve(socket));
- socket.on('error', reject);
- })));
-
- try {
- const rawResponse = await new Promise((resolve, reject) => {
- const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => {
- socket.write([
- 'POST /query HTTP/1.1',
- 'Host: 127.0.0.1',
- `X-AWF-Capability: ${CAPABILITY}`,
- 'Content-Type: application/octet-stream',
- 'X-AWF-Query-Version: 2',
- 'X-AWF-Repo: octo/private',
- `X-AWF-Schema-B64: ${SCHEMA}`,
- 'Content-Length: 0',
- '',
- '',
- ].join('\r\n'));
- });
- const chunks: Uint8Array[] = [];
- socket.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
- socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
- socket.on('error', reject);
- });
-
- expect(rawResponse).toContain(CANONICAL_ERROR);
- expect(handled).toHaveLength(0);
- expect(audit.failure).toHaveBeenCalledWith('transport', 'connection-limit');
- } finally {
- for (const socket of holders) socket.destroy();
- }
- });
-});
diff --git a/src/bounded-query/ingress.test.ts b/src/bounded-query/ingress.test.ts
deleted file mode 100644
index 812499eb0..000000000
--- a/src/bounded-query/ingress.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import execa from 'execa';
-import type { WrapperConfig } from '../types';
-import {
- removeSbxIngressCapabilityFile,
- resolveSbxIngress,
-} from './ingress';
-import { resolveBoundedQueryPaths } from './paths';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-jest.mock('../services/host-gateway', () => ({
- resolveDockerHostGateway: jest.fn(() => '172.17.0.1'),
-}));
-const mockExeca = execa as unknown as jest.Mock;
-
-describe('sbx bounded-query ingress resolution', () => {
- let workDir: string;
- let config: WrapperConfig;
-
- beforeEach(() => {
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-ingress-resolution-'));
- config = {
- workDir,
- boundedQueryIngressTransport: 'sbx-http',
- } as WrapperConfig;
- const paths = resolveBoundedQueryPaths(workDir);
- fs.mkdirSync(paths.controlDir, { recursive: true, mode: 0o700 });
- fs.writeFileSync(paths.capabilityPath, JSON.stringify({
- version: 1,
- query: 'a'.repeat(64),
- probe: 'b'.repeat(64),
- }), { mode: 0o600 });
- mockExeca.mockReset();
- mockExeca.mockResolvedValue({
- exitCode: 0,
- stdout: 'healthy|172.17.0.1:49152\n',
- stderr: '',
- });
- });
-
- afterEach(() => {
- const paths = resolveBoundedQueryPaths(workDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('returns only the endpoint, two capabilities, and agent-visible artifact paths', async () => {
- const result = await resolveSbxIngress(config);
- const paths = resolveBoundedQueryPaths(workDir);
-
- expect(result).toEqual({
- endpoint: 'http://host.docker.internal:49152/query',
- queryCapability: 'a'.repeat(64),
- probeCapability: 'b'.repeat(64),
- skillPath: paths.skillPath,
- wrapperDir: paths.agentDir,
- });
- const dockerArgs = mockExeca.mock.calls[0][1] as string[];
- expect(dockerArgs.join(' ')).not.toContain('a'.repeat(64));
- expect(dockerArgs.join(' ')).not.toContain('b'.repeat(64));
- });
-
- it.each([
- '0.0.0.0:49152',
- '[::1]:49152',
- '172.17.0.1:0',
- '172.17.0.1:70000',
- '',
- ])('rejects a broad or malformed publication: %s', async (published) => {
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: `healthy|${published}`, stderr: '' });
- await expect(resolveSbxIngress(config)).rejects.toThrow(/narrowly published/);
- });
-
- it('waits for broker health before returning the endpoint', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'starting|', stderr: '' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'healthy|172.17.0.1:49152', stderr: '' });
-
- const result = await resolveSbxIngress(config);
- expect(result.endpoint).toBe('http://host.docker.internal:49152/query');
- expect(mockExeca.mock.calls.length).toBeGreaterThanOrEqual(2);
- });
-
- it('removes the private capability file after broker startup and sbx probing', () => {
- const capabilityPath = resolveBoundedQueryPaths(workDir).capabilityPath;
- expect(fs.existsSync(capabilityPath)).toBe(true);
- removeSbxIngressCapabilityFile(config);
- expect(fs.existsSync(capabilityPath)).toBe(false);
- });
-});
diff --git a/src/bounded-query/ingress.ts b/src/bounded-query/ingress.ts
deleted file mode 100644
index bc95bc85e..000000000
--- a/src/bounded-query/ingress.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import * as fs from 'fs';
-import execa from 'execa';
-import { BOUNDED_QUERY_BROKER_CONTAINER_NAME } from '../constants';
-import { getLocalDockerEnv } from '../host-env';
-import { resolveDockerHostGateway } from '../services/host-gateway';
-import type { WrapperConfig } from '../types';
-import { resolveBoundedQueryPaths } from './paths';
-
-export const BOUNDED_QUERY_TCP_PORT = 18080;
-export const BOUNDED_QUERY_INGRESS_NETWORK = 'awf-bounded-query-ingress';
-export const SBX_HOST_ALIAS = 'host.docker.internal';
-
-interface SbxIngressCapabilities {
- version: 1;
- query: string;
- probe: string;
-}
-
-export interface ResolvedSbxIngress {
- endpoint: string;
- queryCapability: string;
- probeCapability: string;
- skillPath: string;
- wrapperDir: string;
-}
-
-function readCapabilities(config: WrapperConfig): SbxIngressCapabilities {
- const paths = resolveBoundedQueryPaths(config.workDir);
- const parsed = JSON.parse(fs.readFileSync(paths.capabilityPath, 'utf8')) as Partial;
- const capabilityPattern = /^[0-9a-f]{64}$/;
- if (
- parsed.version !== 1
- || typeof parsed.query !== 'string'
- || typeof parsed.probe !== 'string'
- || !capabilityPattern.test(parsed.query)
- || !capabilityPattern.test(parsed.probe)
- || parsed.query === parsed.probe
- ) {
- throw new Error('Bounded-query sbx ingress capability file is malformed');
- }
- return parsed as SbxIngressCapabilities;
-}
-
-/** Resolves the healthy host-gateway publication without logging capabilities. */
-export async function resolveSbxIngress(config: WrapperConfig): Promise {
- if (config.boundedQueryIngressTransport !== 'sbx-http') {
- throw new Error('resolveSbxIngress called for a non-HTTP bounded-query transport');
- }
- const expectedHostIp = resolveDockerHostGateway();
- if (!expectedHostIp) {
- throw new Error('Could not resolve the Docker host-gateway IP for bounded-query sbx ingress');
- }
-
- const deadline = Date.now() + 30_000;
- let lastPublished = '';
- let lastHealth = '';
- while (Date.now() < deadline) {
- const result = await execa(
- 'docker',
- [
- 'inspect',
- '--format',
- `{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{with index (index .NetworkSettings.Ports "${BOUNDED_QUERY_TCP_PORT}/tcp") 0}}{{.HostIp}}:{{.HostPort}}{{end}}`,
- BOUNDED_QUERY_BROKER_CONTAINER_NAME,
- ],
- {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 5_000,
- },
- );
- const [health = '', published = ''] = result.stdout.trim().split('|', 2);
- lastHealth = health;
- lastPublished = published;
- const separator = published.lastIndexOf(':');
- const publishedHostIp = separator === -1 ? '' : published.slice(0, separator);
- const publishedPort = separator === -1 ? '' : published.slice(separator + 1);
- const publishedPortNumber = Number(publishedPort);
- const hasValidPort = /^[1-9][0-9]{0,4}$/.test(publishedPort) && publishedPortNumber <= 65535;
- if (result.exitCode === 0 && health === 'healthy' && publishedHostIp === expectedHostIp && hasValidPort) {
- const paths = resolveBoundedQueryPaths(config.workDir);
- const capabilities = readCapabilities(config);
- return {
- endpoint: `http://${SBX_HOST_ALIAS}:${publishedPort}/query`,
- queryCapability: capabilities.query,
- probeCapability: capabilities.probe,
- skillPath: paths.skillPath,
- wrapperDir: paths.agentDir,
- };
- }
- if (result.exitCode === 0 && health === 'healthy') {
- throw new Error(`Bounded-query sbx ingress is not narrowly published on host-gateway ${expectedHostIp}`);
- }
- await new Promise((resolve) => setTimeout(resolve, 1_000));
- }
-
- throw new Error(
- `Bounded-query sbx ingress did not become healthy on host-gateway ${expectedHostIp} ` +
- `(health=${lastHealth || 'unknown'}, published=${lastPublished || 'none'})`,
- );
-}
-
-/** Deletes the on-disk secret after the running broker has loaded it. */
-export function removeSbxIngressCapabilityFile(config: WrapperConfig): void {
- fs.rmSync(resolveBoundedQueryPaths(config.workDir).capabilityPath, { force: true });
-}
-
-/** @internal */
-// ts-prune-ignore-next
-export const ingressTestHelpers = { readCapabilities };
diff --git a/src/bounded-query/ledger.test.ts b/src/bounded-query/ledger.test.ts
deleted file mode 100644
index de5ae3aa4..000000000
--- a/src/bounded-query/ledger.test.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import * as path from 'path';
-
-/**
- * Unit tests for the per-repository information-budget ledger.
- *
- * There is no per-query cap: every invocation's schema-derived charge (see
- * `queryBitsForSchema` in `./protocol`) is atomically checked against and
- * debited from the repository's shared run balance. These tests exercise
- * the ledger in isolation, independent of the broker's orchestration.
- */
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const { createLedger } = require(path.join(brokerDir, 'ledger.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-interface Ledger {
- tryDebit(repoKey: string, bits: number): boolean;
- remainingBits(repoKey: string): number | null | undefined;
-}
-
-function buildLedger(seeds: Array<[string, string]>): Ledger {
- return createLedger(new Map(seeds.map(([repo, sensitivity]) => [repo, { seedId: 'seed', sensitivity }])));
-}
-
-describe('createLedger', () => {
- it('starts each repository at its sensitivity-derived run budget', () => {
- const ledger = buildLedger([
- ['octo/pub', 'public'],
- ['octo/int', 'internal'],
- ['octo/conf', 'confidential'],
- ['octo/sealed', 'sealed'],
- ]);
-
- expect(ledger.remainingBits('octo/pub')).toBeNull();
- expect(ledger.remainingBits('octo/int')).toBe(64);
- expect(ledger.remainingBits('octo/conf')).toBe(8);
- expect(ledger.remainingBits('octo/sealed')).toBe(0);
- });
-
- it('returns undefined for a repository outside the ledger', () => {
- const ledger = buildLedger([['octo/int', 'internal']]);
- expect(ledger.remainingBits('octo/unknown')).toBeUndefined();
- });
-
- it('debits exactly the requested charge on success', () => {
- const ledger = buildLedger([['octo/int', 'internal']]);
- expect(ledger.tryDebit('octo/int', 10)).toBe(true);
- expect(ledger.remainingBits('octo/int')).toBe(54);
- expect(ledger.tryDebit('octo/int', 54)).toBe(true);
- expect(ledger.remainingBits('octo/int')).toBe(0);
- });
-
- it('denies (without debiting) a charge exceeding the remaining balance', () => {
- const ledger = buildLedger([['octo/conf', 'confidential']]);
- expect(ledger.tryDebit('octo/conf', 9)).toBe(false);
- expect(ledger.remainingBits('octo/conf')).toBe(8);
- });
-
- it('allows a charge exactly equal to the remaining balance (exhausting it)', () => {
- const ledger = buildLedger([['octo/conf', 'confidential']]);
- expect(ledger.tryDebit('octo/conf', 8)).toBe(true);
- expect(ledger.remainingBits('octo/conf')).toBe(0);
- // Even the cheapest possible charge (4 bits: 1 status + 0 const + 3 timing) is now unaffordable.
- expect(ledger.tryDebit('octo/conf', 4)).toBe(false);
- });
-
- it('a sealed (0-bit) repository can never afford any positive charge', () => {
- const ledger = buildLedger([['octo/sealed', 'sealed']]);
- expect(ledger.tryDebit('octo/sealed', 1)).toBe(false);
- expect(ledger.tryDebit('octo/sealed', 0)).toBe(true); // A zero-bit charge is not physically possible in practice (min charge is 4), but is not itself unaffordable.
- expect(ledger.remainingBits('octo/sealed')).toBe(0);
- });
-
- it('a public (unmetered) repository can never be exhausted regardless of charge size', () => {
- const ledger = buildLedger([['octo/pub', 'public']]);
- expect(ledger.tryDebit('octo/pub', 1_000_000)).toBe(true);
- expect(ledger.tryDebit('octo/pub', Number.MAX_SAFE_INTEGER)).toBe(true);
- expect(ledger.remainingBits('octo/pub')).toBeNull();
- });
-
- it('denies a debit against an unknown repository', () => {
- const ledger = buildLedger([['octo/int', 'internal']]);
- expect(ledger.tryDebit('octo/unknown', 1)).toBe(false);
- });
-
- it('tracks balances independently per repository', () => {
- const ledger = buildLedger([
- ['octo/a', 'internal'],
- ['octo/b', 'internal'],
- ]);
- expect(ledger.tryDebit('octo/a', 60)).toBe(true);
- expect(ledger.remainingBits('octo/a')).toBe(4);
- expect(ledger.remainingBits('octo/b')).toBe(64);
- });
-
- it('never refunds a charge, regardless of the invocation outcome', () => {
- // The ledger API has no refund/credit operation at all — modeling the
- // "never refunded" guarantee structurally rather than behaviorally.
- const ledger = buildLedger([['octo/int', 'internal']]);
- expect(Object.keys(ledger)).not.toContain('refund');
- expect(Object.keys(ledger)).not.toContain('credit');
- });
-
- it('accumulates many small debits down to exactly zero remaining', () => {
- const ledger = buildLedger([['octo/int', 'internal']]);
- for (let i = 0; i < 16; i++) {
- expect(ledger.tryDebit('octo/int', 4)).toBe(true);
- }
- expect(ledger.remainingBits('octo/int')).toBe(0);
- expect(ledger.tryDebit('octo/int', 1)).toBe(false);
- });
-});
diff --git a/src/bounded-query/manager.test.ts b/src/bounded-query/manager.test.ts
deleted file mode 100644
index 8a3af7220..000000000
--- a/src/bounded-query/manager.test.ts
+++ /dev/null
@@ -1,452 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import execa from 'execa';
-import type { BoundedQueriesConfig, WrapperConfig } from '../types';
-import { resolveBoundedQueryPaths } from './paths';
-import {
- BOUNDED_QUERY_RUN_LABEL,
- isBoundedQueriesEnabled,
- managerTestHelpers,
- prepareBoundedQueries,
- teardownBoundedQueries,
-} from './manager';
-import { releaseSeedPermissions, type GitRunner } from './staging';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-jest.mock('./staging', () => {
- const actual = jest.requireActual('./staging');
- return {
- ...actual,
- releaseSeedPermissions: jest.fn(actual.releaseSeedPermissions),
- };
-});
-const mockExeca = execa as unknown as jest.Mock;
-const mockReleaseSeedPermissions = releaseSeedPermissions as jest.MockedFunction;
-
-const boundedQueries: BoundedQueriesConfig = {
- enabled: true,
- privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
- runtime: 'docker',
- timeout: 30,
- memoryLimit: '512m',
- interpreter: 'python3',
- maxInvocations: 7,
-};
-
-const gitRunner: GitRunner = async (args) => {
- if (args.includes('clone')) {
- const dest = args[args.length - 1];
- fs.mkdirSync(path.join(dest, '.git'), { recursive: true });
- fs.writeFileSync(path.join(dest, '.git', 'config'), '[core]\n');
- fs.writeFileSync(path.join(dest, 'README.md'), 'contents\n');
- return { stdout: '' };
- }
- if (args[0] === 'rev-parse') return { stdout: 'a'.repeat(40) };
- return { stdout: '' };
-};
-
-function buildConfig(workDir: string, overrides: Partial = {}): WrapperConfig {
- return { workDir, boundedQueries: { ...boundedQueries, ...overrides } } as unknown as WrapperConfig;
-}
-
-describe('isBoundedQueriesEnabled', () => {
- it('is true only for an explicitly enabled config', () => {
- expect(isBoundedQueriesEnabled({} as WrapperConfig)).toBe(false);
- expect(isBoundedQueriesEnabled(buildConfig('/tmp/x', { enabled: false }))).toBe(false);
- expect(isBoundedQueriesEnabled(buildConfig('/tmp/x'))).toBe(true);
- });
-});
-
-describe('prepareBoundedQueries', () => {
- let workDir: string;
-
- beforeEach(() => {
- mockExeca.mockReset();
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' });
- mockReleaseSeedPermissions.mockImplementation(
- jest.requireActual('./staging').releaseSeedPermissions,
- );
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-manager-'));
- });
-
- afterEach(() => {
- const paths = resolveBoundedQueryPaths(workDir);
- releaseSeedPermissions(paths.seedsDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('does nothing when bounded queries are disabled', async () => {
- await prepareBoundedQueries(buildConfig(workDir, { enabled: false }), { env: { GH_TOKEN: 't' }, gitRunner });
- expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false);
- });
-
- it('creates the directory layout, seed map, skill, and wrapper artifacts', async () => {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner });
- const paths = resolveBoundedQueryPaths(workDir);
-
- expect(fs.existsSync(paths.seedsDir)).toBe(true);
- expect(fs.existsSync(paths.workDir)).toBe(true);
- expect(fs.existsSync(paths.runDir)).toBe(true);
- expect(fs.existsSync(paths.auditDir)).toBe(true);
- expect(fs.existsSync(paths.controlDir)).toBe(true);
- expect(fs.existsSync(paths.skillPath)).toBe(true);
- expect(fs.existsSync(paths.wrapperPath)).toBe(true);
- expect(fs.statSync(paths.wrapperPath).mode & 0o777).toBe(0o555);
- expect(paths.root.startsWith(workDir)).toBe(false);
-
- const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8'));
- expect(seedMap.version).toBe(2);
- expect(seedMap.runId).toMatch(/^[0-9a-f]{32}$/);
- expect(seedMap.seeds).toEqual([
- { repo: 'octo/private', seedId: expect.stringMatching(/^[0-9a-f]{32}$/), sensitivity: 'internal' },
- ]);
- expect(fs.statSync(paths.seedMapPath).mode & 0o777).toBe(0o600);
- });
-
- it.each([
- [true, 'unix'],
- [false, 'sbx-http'],
- ] as const)('selects sbx ingress from the executable socket probe (%s)', async (supported, expected) => {
- const config = {
- ...buildConfig(workDir),
- containerRuntime: 'sbx',
- };
- const probe = jest.fn().mockResolvedValue(supported);
-
- await prepareBoundedQueries(config, {
- env: { GH_TOKEN: 't' },
- gitRunner,
- probeSbxUnixSocket: probe,
- });
-
- const paths = resolveBoundedQueryPaths(workDir);
- expect(config.boundedQueryIngressTransport).toBe(expected);
- expect(probe).toHaveBeenCalledTimes(1);
- expect(fs.existsSync(paths.capabilityPath)).toBe(!supported);
- if (!supported) {
- const raw = fs.readFileSync(paths.capabilityPath, 'utf8');
- const capabilities = JSON.parse(raw);
- expect(raw).not.toContain('GH_TOKEN');
- expect(capabilities).toEqual({
- version: 1,
- query: expect.stringMatching(/^[0-9a-f]{64}$/),
- probe: expect.stringMatching(/^[0-9a-f]{64}$/),
- });
- expect(capabilities.query).not.toBe(capabilities.probe);
- expect(fs.statSync(paths.capabilityPath).mode & 0o777).toBe(0o600);
- }
- });
-
- it('keeps the seed map free of host paths and credentials', async () => {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 'ghs_secret' }, gitRunner });
- const raw = fs.readFileSync(resolveBoundedQueryPaths(workDir).seedMapPath, 'utf8');
-
- expect(raw).not.toContain('ghs_secret');
- expect(raw).not.toContain(workDir);
- });
-
- it('protects broker-only directories and shares only the run/agent directories', async () => {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner });
- const paths = resolveBoundedQueryPaths(workDir);
-
- expect(fs.statSync(paths.seedsDir).mode & 0o777).toBe(0o700);
- expect(fs.statSync(paths.auditDir).mode & 0o777).toBe(0o700);
- expect(fs.statSync(paths.workDir).mode & 0o777).toBe(0o700);
- expect(fs.statSync(paths.runDir).mode & 0o777).toBe(0o770);
- });
-
- it('aborts when the configuration is invalid', async () => {
- await expect(
- prepareBoundedQueries(buildConfig(workDir, { privateRepos: [] }), { env: { GH_TOKEN: 't' }, gitRunner }),
- ).rejects.toThrow(/configuration is invalid/);
- });
-
- it('aborts when no staging credential is available', async () => {
- await expect(
- prepareBoundedQueries(buildConfig(workDir), { env: {}, gitRunner }),
- ).rejects.toThrow(/GH_TOKEN or GITHUB_TOKEN/);
- });
-
- it('aborts if the staging credential disappears after validation', async () => {
- let reads = 0;
- const env = {
- get GH_TOKEN() {
- reads += 1;
- return reads === 1 ? 't' : undefined;
- },
- } as NodeJS.ProcessEnv;
-
- await expect(prepareBoundedQueries(buildConfig(workDir), { env, gitRunner }))
- .rejects.toThrow(/credential disappeared/);
- });
-
- it('rejects a symlink work directory before staging', async () => {
- const target = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-manager-target-'));
- const link = path.join(os.tmpdir(), `awf-bounded-query-manager-link-${process.pid}-${Date.now()}`);
- fs.symlinkSync(target, link);
- try {
- await expect(prepareBoundedQueries(buildConfig(link), { env: { GH_TOKEN: 't' }, gitRunner }))
- .rejects.toThrow(/symlink work directory/);
- } finally {
- fs.unlinkSync(link);
- fs.rmSync(target, { recursive: true, force: true });
- }
- });
-
- it('rejects a pre-existing private root instead of reusing attacker-controlled state', async () => {
- const paths = resolveBoundedQueryPaths(workDir);
- fs.mkdirSync(paths.root);
- await expect(prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }))
- .rejects.toThrow(/EEXIST|file already exists/);
- });
-
- it('rejects a pre-existing ingress root instead of following a planted symlink', async () => {
- const paths = resolveBoundedQueryPaths(workDir);
- const target = fs.mkdtempSync(path.join('/var/tmp', 'awf-bounded-query-ingress-target-'));
- fs.symlinkSync(target, paths.ingressRoot);
- try {
- await expect(prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }))
- .rejects.toThrow(/EEXIST|file already exists/);
- } finally {
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- fs.rmSync(target, { recursive: true, force: true });
- }
- });
-
- it('aborts when a seed cannot be staged', async () => {
- const failing: GitRunner = async () => {
- throw new Error('fatal: repository not found');
- };
-
- await expect(
- prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner: failing }),
- ).rejects.toThrow(/staging failed/);
- });
-
- it.each(['docker', 'gvisor', 'sbx'] as const)(
- 'fails query runtime %s capability preflight before directories or staging',
- async (runtime) => {
- const assertRuntimeAvailable = jest.fn().mockRejectedValue(new Error(`${runtime} unavailable`));
- const probeSbxUnixSocket = jest.fn();
- const config = buildConfig(workDir, { runtime });
- await expect(prepareBoundedQueries(config, {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertRuntimeAvailable,
- probeSbxUnixSocket,
- })).rejects.toThrow(`${runtime} unavailable`);
- expect(assertRuntimeAvailable).toHaveBeenCalledTimes(1);
- expect(probeSbxUnixSocket).not.toHaveBeenCalled();
- expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false);
- },
- );
-
- it.each([undefined, 'gvisor', 'sbx'] as const)(
- 'fails primary runtime %s capability preflight before query preflight or staging',
- async (containerRuntime) => {
- const assertPrimaryAvailable = jest.fn().mockRejectedValue(new Error('primary unavailable'));
- const assertRuntimeAvailable = jest.fn();
- const probeSbxUnixSocket = jest.fn();
- await expect(prepareBoundedQueries(
- { ...buildConfig(workDir), containerRuntime },
- {
- env: { GH_TOKEN: 't' },
- gitRunner,
- assertPrimaryAvailable,
- assertRuntimeAvailable,
- probeSbxUnixSocket,
- },
- )).rejects.toThrow('primary unavailable');
- expect(assertRuntimeAvailable).not.toHaveBeenCalled();
- expect(probeSbxUnixSocket).not.toHaveBeenCalled();
- expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false);
- },
- );
-});
-
-describe('teardownBoundedQueries', () => {
- beforeEach(() => {
- mockExeca.mockReset();
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' });
- });
-
- it('is a no-op when bounded queries were never enabled', async () => {
- await expect(teardownBoundedQueries({ workDir: '/nonexistent' } as WrapperConfig)).resolves.toBeUndefined();
- });
-
- it('restores seed write permissions so generic cleanup can remove them', async () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-teardown-'));
- const paths = resolveBoundedQueryPaths(workDir);
- try {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner });
-
- expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).toThrow();
-
- // No query containers exist for this run, so the docker lookup is a
- // no-op; the permission restore is what must happen.
- await teardownBoundedQueries(buildConfig(workDir));
-
- expect(fs.existsSync(paths.root)).toBe(false);
- expect(fs.existsSync(paths.ingressRoot)).toBe(false);
- } finally {
- releaseSeedPermissions(paths.seedsDir);
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
-
- it('leaves the seeds read-only under --keep-containers', async () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-keep-'));
- try {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner });
- const paths = resolveBoundedQueryPaths(workDir);
-
- await teardownBoundedQueries({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig);
-
- expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).toThrow();
- } finally {
- const cleanupPaths = resolveBoundedQueryPaths(workDir);
- releaseSeedPermissions(cleanupPaths.seedsDir);
- fs.rmSync(cleanupPaths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
-
- it('removes every orphaned query container for the staged run', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'query-a\nquery-b\n' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '' });
-
- await managerTestHelpers.removeOrphanQueryContainers('run-id');
-
- expect(mockExeca).toHaveBeenNthCalledWith(
- 1,
- 'docker',
- ['ps', '-aq', '--filter', `label=${BOUNDED_QUERY_RUN_LABEL}=run-id`],
- expect.objectContaining({ reject: false }),
- );
- expect(mockExeca).toHaveBeenNthCalledWith(
- 2,
- 'docker',
- ['rm', '-f', 'query-a', 'query-b'],
- expect.objectContaining({ reject: false }),
- );
- });
-
- it('does not remove containers when the Docker listing fails', async () => {
- mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: 'query-a' });
-
- await managerTestHelpers.removeOrphanQueryContainers('run-id');
-
- expect(mockExeca).toHaveBeenCalledTimes(1);
- });
-
- it('is a no-op when the bounded-query root is absent', async () => {
- await teardownBoundedQueries(buildConfig('/nonexistent/bounded-query-work-dir'));
- expect(mockExeca).not.toHaveBeenCalled();
- });
-
- it('handles unreadable or unusable seed maps without attempting Docker cleanup', async () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-bad-map-'));
- const paths = resolveBoundedQueryPaths(workDir);
- fs.mkdirSync(paths.root, { recursive: true });
- fs.writeFileSync(paths.seedMapPath, '{bad json');
- try {
- await teardownBoundedQueries(buildConfig(workDir));
- expect(mockExeca).not.toHaveBeenCalled();
- } finally {
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
-
- it('continues cleanup when orphan container removal fails', async () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-orphan-failure-'));
- try {
- await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner });
- mockExeca.mockRejectedValueOnce(new Error('docker unavailable'));
-
- await expect(teardownBoundedQueries(buildConfig(workDir))).resolves.toBeUndefined();
- expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false);
- } finally {
- const cleanupPaths = resolveBoundedQueryPaths(workDir);
- releaseSeedPermissions(cleanupPaths.seedsDir);
- fs.rmSync(cleanupPaths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
-
- it('does not fail teardown when seed permissions cannot be restored', async () => {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-permission-failure-'));
- const paths = resolveBoundedQueryPaths(workDir);
- try {
- fs.mkdirSync(paths.root, { recursive: true });
- fs.writeFileSync(paths.seedMapPath, JSON.stringify({ runId: '' }));
- fs.writeFileSync(paths.seedsDir, 'not a directory');
- mockReleaseSeedPermissions.mockImplementationOnce(() => {
- throw new Error('permission denied');
- });
-
- await expect(teardownBoundedQueries(buildConfig(workDir))).resolves.toBeUndefined();
- expect(mockReleaseSeedPermissions).toHaveBeenCalledWith(paths.seedsDir);
- } finally {
- fs.rmSync(paths.root, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- });
-
- it('repairs rootless private-state permissions and retries cleanup', () => {
- const paths = resolveBoundedQueryPaths('/tmp/rootless-cleanup');
- const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
- const removeTree = jest.fn()
- .mockImplementationOnce(() => { throw permissionError; })
- .mockImplementation(() => undefined);
- const repairPermissions = jest.fn();
-
- managerTestHelpers.removePrivateState(
- buildConfig('/tmp/rootless-cleanup'),
- paths,
- { removeTree, repairPermissions },
- );
-
- expect(repairPermissions).toHaveBeenCalledWith(
- [paths.root, paths.ingressRoot],
- undefined,
- undefined,
- undefined,
- undefined,
- );
- expect(removeTree).toHaveBeenCalledTimes(3);
- });
-
- it('surfaces cleanup failures after rootless permission repair', () => {
- const paths = resolveBoundedQueryPaths('/tmp/rootless-retry-failure');
- const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
- const removeTree = jest.fn()
- .mockImplementationOnce(() => { throw permissionError; })
- .mockImplementationOnce(() => { throw new Error('still denied'); });
-
- expect(() => managerTestHelpers.removePrivateState(
- buildConfig('/tmp/rootless-retry-failure'),
- paths,
- { removeTree, repairPermissions: jest.fn() },
- )).not.toThrow();
- });
-
- it('surfaces non-permission cleanup failures without attempting repair', () => {
- const paths = resolveBoundedQueryPaths('/tmp/private-cleanup-failure');
- const repairPermissions = jest.fn();
-
- expect(() => managerTestHelpers.removePrivateState(
- buildConfig('/tmp/private-cleanup-failure'),
- paths,
- {
- removeTree: () => { throw new Error('I/O failure'); },
- repairPermissions,
- },
- )).not.toThrow();
- expect(repairPermissions).not.toHaveBeenCalled();
- });
-});
diff --git a/src/bounded-query/manager.ts b/src/bounded-query/manager.ts
deleted file mode 100644
index 60dafbdbf..000000000
--- a/src/bounded-query/manager.ts
+++ /dev/null
@@ -1,387 +0,0 @@
-import * as fs from 'fs';
-import * as crypto from 'crypto';
-import execa from 'execa';
-import { logger } from '../logger';
-import { getLocalDockerEnv } from '../host-env';
-import { getSafeHostUid, getSafeHostGid } from '../host-identity';
-import type { WrapperConfig } from '../types';
-import {
- generateBoundedQueryRunId,
- resolveBoundedQueryPaths,
- type BoundedQueryPaths,
-} from './paths';
-import {
- assertPrimaryRuntimeAvailable,
- assertQueryRuntimeAvailable,
- validateBoundedQueryConfig,
-} from './preflight';
-import { writeBoundedQuerySkill } from './skill';
-import { writeBoundedQueryWrapper } from './wrapper-artifact';
-import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from './staging';
-import {
- BOUNDED_QUERY_SEED_MAP_VERSION,
- serializePrivateRepositorySeedMap,
- type BoundedQuerySeedMap,
-} from './types';
-import { assertBoundedQueryPrivateRootIsolated } from './mount-policy';
-import { fixArtifactPermissionsForRootless } from '../artifact-permissions';
-import { runtimeUsesComposeAgent } from '../container-runtime';
-import { probeSbxUnixSocketMount } from '../sbx-manager';
-import {
- resolveBoundedQueryPrimaryBackend,
- serializeBoundedQueryRuntimeTelemetry,
-} from './runtime-matrix';
-import {
- type SbxIngressCapabilities,
- writeSbxIngressCapabilitiesFile,
-} from '../bounded-execution/sbx-ingress-capabilities';
-
-/**
- * Bounded-query lifecycle orchestration.
- *
- * `prepareBoundedQueries` runs entirely on the trusted AWF host **before** any
- * configuration is generated or any container is started, so that:
- *
- * - the primary agent never starts when staging fails;
- * - the staging credential is consumed and discarded before the broker, the
- * agent, and any query exist;
- * - compose generation can rely on the on-disk layout already being present.
- *
- * `teardownBoundedQueries` removes orphaned query containers and the separate
- * broker-private host root.
- */
-
-/** Docker label applied to every query container, used for orphan cleanup. */
-export const BOUNDED_QUERY_RUN_LABEL = 'awf.bounded-query.run';
-
-/** Returns true when this run must stage seeds and start the broker. */
-export function isBoundedQueriesEnabled(config: WrapperConfig): boolean {
- return config.boundedQueries?.enabled === true;
-}
-
-/** Creates a directory with an exact mode, independent of the process umask. */
-function ensureModeDirectory(target: string, mode: number): void {
- fs.mkdirSync(target, { recursive: true, mode });
- fs.chmodSync(target, mode);
-}
-
-/**
- * Creates the bounded-query directory layout.
- *
- * The private root is created without `recursive` so a pre-existing path,
- * including a symlink planted between preflight and creation, fails closed.
- */
-function prepareDirectories(paths: BoundedQueryPaths): void {
- fs.mkdirSync(paths.root, { mode: 0o700 });
- fs.mkdirSync(paths.ingressRoot, { mode: 0o700 });
- ensureModeDirectory(paths.seedsDir, 0o700);
- ensureModeDirectory(paths.workDir, 0o700);
- ensureModeDirectory(paths.controlDir, 0o700);
- ensureModeDirectory(paths.auditDir, 0o700);
- ensureModeDirectory(paths.runDir, 0o770);
- ensureModeDirectory(paths.agentDir, 0o755);
-
- try {
- fs.chownSync(paths.runDir, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10));
- } catch {
- // Non-root host (e.g. network-isolation mode): the broker chowns/chmods
- // the socket itself once it is bound.
- }
-}
-
-interface RemovePrivateStateDeps {
- removeTree?: (target: string) => void;
- repairPermissions?: typeof fixArtifactPermissionsForRootless;
-}
-
-function removePrivateState(
- config: WrapperConfig,
- paths: BoundedQueryPaths,
- deps: RemovePrivateStateDeps = {},
-): void {
- const removeTree = deps.removeTree ?? ((target: string) => {
- fs.rmSync(target, { recursive: true, force: true });
- });
- const repairPermissions = deps.repairPermissions ?? fixArtifactPermissionsForRootless;
-
- try {
- removeTree(paths.root);
- removeTree(paths.ingressRoot);
- } catch (error: unknown) {
- if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') {
- logger.debug('Bounded queries: repairing rootless private-state permissions before cleanup');
- repairPermissions(
- [paths.root, paths.ingressRoot],
- config.dockerHostPathPrefix,
- config.imageRegistry,
- config.imageTag,
- config.agentImage,
- );
- try {
- removeTree(paths.root);
- removeTree(paths.ingressRoot);
- } catch (retryError) {
- logger.warn('Bounded queries: failed to remove private state after permission repair', retryError);
- }
- return;
- }
- logger.warn('Bounded queries: failed to remove private state during cleanup', error);
- }
-}
-
-/** Writes the broker's repo → opaque seed map. */
-function writeSeedMap(paths: BoundedQueryPaths, seedMap: BoundedQuerySeedMap): void {
- const content = serializePrivateRepositorySeedMap(seedMap);
- // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file
- // is already at this path (insecure-temp-file guard).
- const fd = fs.openSync(
- paths.seedMapPath,
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
- 0o600,
- );
- try {
- fs.writeSync(fd, content);
- fs.fchmodSync(fd, 0o600);
- } finally {
- fs.closeSync(fd);
- }
-}
-
-export interface PrepareBoundedQueriesDeps {
- /** Override the git runner (tests). */
- gitRunner?: GitRunner;
- /** Override the host environment the staging credential is read from. */
- env?: NodeJS.ProcessEnv;
- /** Override the sbx Unix-socket passthrough probe (tests). */
- probeSbxUnixSocket?: () => Promise;
- /** Override query-runtime capability preflight (tests). */
- assertRuntimeAvailable?: typeof assertQueryRuntimeAvailable;
- /** Override primary-runtime capability preflight (tests). */
- assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable;
-}
-
-function writeSbxIngressCapabilities(paths: BoundedQueryPaths): void {
- const capabilities: SbxIngressCapabilities = {
- version: 1,
- query: crypto.randomBytes(32).toString('hex'),
- probe: crypto.randomBytes(32).toString('hex'),
- };
- writeSbxIngressCapabilitiesFile(paths.capabilityPath, capabilities);
-}
-
-/**
- * Validates configuration, stages one immutable seed per configured
- * repository, and writes the broker/agent artifacts.
- *
- * Throws on any failure — the caller must abort the run.
- */
-export async function prepareBoundedQueries(
- config: WrapperConfig,
- deps: PrepareBoundedQueriesDeps = {},
-): Promise {
- const boundedQueries = config.boundedQueries;
- if (!boundedQueries?.enabled) return;
-
- const env = deps.env ?? process.env;
- const errors = validateBoundedQueryConfig(config, env);
- if (errors.length > 0) {
- throw new Error(`Bounded-query configuration is invalid:\n - ${errors.join('\n - ')}`);
- }
-
- const primaryBackend = resolveBoundedQueryPrimaryBackend(config.containerRuntime);
- const telemetryBase = {
- primaryBackend,
- queryBackend: boundedQueries.runtime,
- lifecycleClass: 'preflight' as const,
- };
- const assertRuntimeAvailable = deps.assertRuntimeAvailable ?? assertQueryRuntimeAvailable;
- const assertPrimaryAvailable = deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable;
- try {
- await assertPrimaryAvailable(config.containerRuntime);
- } catch (error) {
- logger.info(
- `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: 'unavailable',
- category: 'primary-runtime-unavailable',
- })}`,
- );
- throw error;
- }
- try {
- await assertRuntimeAvailable(boundedQueries);
- } catch (error) {
- logger.info(
- `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: boundedQueries.runtime === 'sbx' ? 'blocked' : 'unavailable',
- category: boundedQueries.runtime === 'sbx' ? 'query-security-block' : 'query-runtime-unavailable',
- })}`,
- );
- throw error;
- }
- logger.info(
- `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({
- ...telemetryBase,
- capabilityState: 'supported',
- category: 'ready',
- })}`,
- );
-
- if (runtimeUsesComposeAgent(config.containerRuntime)) {
- config.boundedQueryIngressTransport = 'unix';
- } else {
- const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount;
- config.boundedQueryIngressTransport = (await probe()) ? 'unix' : 'sbx-http';
- }
-
- const paths = resolveBoundedQueryPaths(config.workDir);
- assertBoundedQueryPrivateRootIsolated(config, paths, env);
-
- const token = resolveStagingToken(env);
- if (!token) {
- // Already covered by validateBoundedQueryConfig; re-checked so the token is
- // never `undefined!`-asserted into the staging call.
- throw new Error('Bounded-query staging credential disappeared between validation and staging');
- }
-
- // Guard against symlink injection before writing any credential-bearing state.
- // The generic work-directory check in config-writer.ts runs later (during
- // writeConfigs), so we apply the same symlink rejection here explicitly.
- try {
- const lstat = fs.lstatSync(config.workDir);
- if (lstat.isSymbolicLink()) {
- throw new Error(`Refusing to stage into a symlink work directory: ${config.workDir}`);
- }
- } catch (error: unknown) {
- // If lstatSync throws because the directory doesn't exist yet, that is
- // fine — prepareDirectories will create it. Any other error propagates.
- if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
- throw error;
- }
- }
-
- prepareDirectories(paths);
- if (config.boundedQueryIngressTransport === 'sbx-http') {
- writeSbxIngressCapabilities(paths);
- }
-
- const runId = generateBoundedQueryRunId();
- const staging = await stageBoundedQuerySeeds({
- repos: boundedQueries.privateRepos,
- paths,
- runId,
- token,
- gitRunner: deps.gitRunner,
- });
-
- writeSeedMap(paths, {
- version: BOUNDED_QUERY_SEED_MAP_VERSION,
- runId: staging.runId,
- seeds: staging.seeds.map((seed) => ({
- repo: seed.repoKey,
- seedId: seed.seedId,
- sensitivity: seed.sensitivity,
- })),
- });
-
- writeBoundedQuerySkill(paths, {
- repos: boundedQueries.privateRepos,
- timeoutSeconds: boundedQueries.timeout,
- maxInvocations: boundedQueries.maxInvocations,
- });
- writeBoundedQueryWrapper(paths);
-
- logger.info(
- `Bounded queries: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`,
- );
-}
-
-/** Reads back the run id recorded during staging, if it is still available. */
-function readRunId(paths: BoundedQueryPaths): string | undefined {
- try {
- const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as BoundedQuerySeedMap;
- return typeof parsed.runId === 'string' && parsed.runId.length > 0 ? parsed.runId : undefined;
- } catch {
- return undefined;
- }
-}
-
-/** Force-removes any query container still labelled with this run. */
-async function removeOrphanQueryContainers(runId: string): Promise {
- const filter = `label=${BOUNDED_QUERY_RUN_LABEL}=${runId}`;
- const listed = await execa('docker', ['ps', '-aq', '--filter', filter], {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 30_000,
- });
- if (listed.exitCode !== 0) return;
-
- const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean);
- if (ids.length === 0) return;
-
- logger.debug(`Bounded queries: removing ${ids.length} orphaned query container(s)`);
- await execa('docker', ['rm', '-f', ...ids], {
- env: getLocalDockerEnv(),
- reject: false,
- timeout: 60_000,
- });
-}
-
-/**
- * Tears down bounded-query state.
- *
- * Orphaned query containers are always removed: they are ephemeral, hold a
- * private copy of repository contents, and are never useful for debugging.
- *
- * Restoring seed permissions is skipped under `--keep-containers`, where the
- * caller explicitly asked to preserve the run's state for inspection. When it
- * does run, it must run before AWF's generic work-directory cleanup: seeds are
- * deliberately read-only, and `rm -rf` cannot unlink entries inside a
- * directory whose write bit was stripped.
- */
-export async function teardownBoundedQueries(config: WrapperConfig): Promise {
- if (!isBoundedQueriesEnabled(config)) return;
-
- const paths = resolveBoundedQueryPaths(config.workDir);
- if (!fs.existsSync(paths.root)) {
- if (!config.keepContainers) {
- fs.rmSync(paths.ingressRoot, { recursive: true, force: true });
- }
- return;
- }
-
- const runId = readRunId(paths);
- if (runId) {
- try {
- await removeOrphanQueryContainers(runId);
- } catch (error) {
- logger.warn('Bounded queries: failed to remove orphaned query containers', error);
- }
- }
-
- if (config.keepContainers) {
- logger.info(`Bounded-query private state preserved at: ${paths.root}`);
- logger.info(`Bounded-query agent ingress preserved at: ${paths.ingressRoot}`);
- return;
- }
-
- try {
- releaseSeedPermissions(paths.seedsDir);
- } catch (error) {
- logger.warn('Bounded queries: failed to restore seed permissions before cleanup', error);
- }
-
- removePrivateState(config, paths);
-}
-
-/** @internal Exported for focused unit tests. */
-// ts-prune-ignore-next
-export const managerTestHelpers = {
- prepareDirectories,
- writeSeedMap,
- readRunId,
- removeOrphanQueryContainers,
- removePrivateState,
- writeSbxIngressCapabilities,
-};
diff --git a/src/bounded-query/mount-policy.test.ts b/src/bounded-query/mount-policy.test.ts
deleted file mode 100644
index ed10b0afa..000000000
--- a/src/bounded-query/mount-policy.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import type { WrapperConfig } from '../types';
-import { assertBoundedQueryPrivateRootIsolated, resolvePathThroughExistingAncestor } from './mount-policy';
-import { resolveBoundedQueryPaths } from './paths';
-
-function config(workDir: string, volumeMounts?: string[]): WrapperConfig {
- return {
- workDir,
- volumeMounts,
- } as unknown as WrapperConfig;
-}
-
-describe('bounded-query private-root mount policy', () => {
- let testRoot: string;
- let workDir: string;
- let privateBase: string;
-
- beforeEach(() => {
- testRoot = fs.mkdtempSync(path.join('/var/tmp', 'awf-bounded-query-policy-'));
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-visible-'));
- privateBase = path.join(testRoot, 'private');
- fs.mkdirSync(privateBase);
- });
-
- afterEach(() => {
- fs.rmSync(testRoot, { recursive: true, force: true });
- fs.rmSync(workDir, { recursive: true, force: true });
- });
-
- it('accepts a dedicated private root outside all agent-visible mounts', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() => assertBoundedQueryPrivateRootIsolated(config(workDir), paths)).not.toThrow();
- });
-
- it('rejects private state beneath the broad /tmp mount', () => {
- const paths = resolveBoundedQueryPaths(workDir, '/tmp');
- expect(() => assertBoundedQueryPrivateRootIsolated(config(workDir), paths))
- .toThrow(/overlaps agent-visible temporary directory/);
- });
-
- it('rejects a broad custom mount containing the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir, [`${testRoot}:/data:ro`]), paths),
- ).toThrow(/custom volume/);
- });
-
- it('rejects a nested custom mount inside the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- const nested = path.join(paths.root, 'seeds');
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir, [`${nested}:/data:ro`]), paths),
- ).toThrow(/custom volume/);
- });
-
- it('normalizes path traversal before checking overlap', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- const traversing = path.join(paths.root, 'seeds', '..');
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir, [`${traversing}:/data:ro`]), paths),
- ).toThrow(/custom volume/);
- });
-
- it('resolves symlink aliases in existing ancestors', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- const alias = path.join(testRoot, 'private-alias');
- fs.symlinkSync(privateBase, alias);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir, [`${alias}:/data:ro`]), paths),
- ).toThrow(/custom volume/);
- });
-
- it('checks daemon-prefixed paths used by DinD bind mounts', () => {
- const daemonRoot = path.join(testRoot, 'daemon');
- fs.mkdirSync(daemonRoot);
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(
- { ...config(workDir, [`${testRoot}:/data:ro`]), dockerHostPathPrefix: daemonRoot },
- paths,
- ),
- ).toThrow(/custom volume/);
- });
-
- it('rejects a workspace that contains the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir), paths, {}, testRoot),
- ).toThrow(/agent-visible workspace/);
- });
-
- it('rejects a configured session-state mount containing the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(
- { ...config(workDir), sessionStateDir: testRoot },
- paths,
- ),
- ).toThrow(/agent session-state directory/);
- });
-
- it('rejects malformed custom mounts instead of ignoring their source', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(config(workDir, ['named-volume:/data:ro']), paths),
- ).toThrow(/could not parse custom bind mount/);
- });
-
- it('rejects a chroot binaries source containing the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(
- { ...config(workDir), chrootBinariesSourcePath: testRoot },
- paths,
- ),
- ).toThrow(/chroot binaries source/);
- });
-
- it('rejects an agent-visible Docker socket path inside the private root', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBase);
- expect(() =>
- assertBoundedQueryPrivateRootIsolated(
- {
- ...config(workDir),
- enableDind: true,
- awfDockerHost: `unix://${path.join(paths.root, 'docker.sock')}`,
- },
- paths,
- ),
- ).toThrow(/agent Docker socket/);
- });
-
- it('resolves a missing suffix through a symlinked ancestor', () => {
- const target = path.join(testRoot, 'target');
- const alias = path.join(testRoot, 'alias');
- fs.mkdirSync(target);
- fs.symlinkSync(target, alias);
- expect(resolvePathThroughExistingAncestor(path.join(alias, 'missing', 'leaf')))
- .toBe(path.join(fs.realpathSync.native(target), 'missing', 'leaf'));
- });
-
- it('rejects relative paths before filesystem resolution', () => {
- expect(() => resolvePathThroughExistingAncestor('../private'))
- .toThrow(/requires an absolute path/);
- });
-});
diff --git a/src/bounded-query/naming.test.ts b/src/bounded-query/naming.test.ts
deleted file mode 100644
index cf21f54a9..000000000
--- a/src/bounded-query/naming.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { spawnSync } from 'child_process';
-
-describe('bounded-query naming', () => {
- it('does not retain the previous feature name in tracked paths or text', () => {
- const oldPrefix = 'sealed';
- const oldNoun = 'probe';
- const forbiddenFragments = [
- `${oldPrefix}-${oldNoun}`,
- `${oldPrefix}_${oldNoun}`,
- `${oldPrefix} ${oldNoun}`,
- `${oldPrefix}${oldNoun}`,
- `${oldPrefix}-query`,
- `${oldPrefix}_query`,
- `${oldPrefix} query`,
- ];
- const trackedFilesResult = spawnSync('git', ['ls-files', '-z'], {
- encoding: 'utf8',
- });
- expect(trackedFilesResult.status).toBe(0);
- const trackedFiles = trackedFilesResult.stdout
- .split('\0')
- .filter(Boolean);
- const pathMatches = trackedFiles.filter((file) => {
- const normalized = file.toLowerCase();
- return forbiddenFragments.some((fragment) => normalized.includes(fragment));
- });
-
- const grepArgs = ['grep', '-I', '-l', '-i', '-z'];
- for (const fragment of forbiddenFragments) {
- grepArgs.push('-e', fragment);
- }
- grepArgs.push('--');
- const contentMatchesResult = spawnSync('git', grepArgs, { encoding: 'utf8' });
- expect([0, 1]).toContain(contentMatchesResult.status);
- const contentMatches = contentMatchesResult.status === 0
- ? contentMatchesResult.stdout.split('\0').filter(Boolean)
- : [];
-
- expect([...new Set([...pathMatches, ...contentMatches])]).toEqual([]);
- });
-});
diff --git a/src/bounded-query/paths.test.ts b/src/bounded-query/paths.test.ts
deleted file mode 100644
index 3ce1e3058..000000000
--- a/src/bounded-query/paths.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import * as path from 'path';
-import {
- AGENT_SKILL_DIR,
- AGENT_SKILL_PATH,
- AGENT_SOCKET_DIR,
- AGENT_SOCKET_PATH,
- deriveSeedId,
- generateBoundedQueryRunId,
- normalizeRepoKey,
- resolveBoundedQueryPaths,
-} from './paths';
-
-describe('bounded-query paths', () => {
- const workDir = '/tmp/awf-12345';
- const privateBaseDir = '/var/tmp/awf-test-private';
-
- it('separates broker-private state from agent-visible ingress', () => {
- const paths = resolveBoundedQueryPaths(workDir, privateBaseDir);
-
- expect(paths.root.startsWith(`${privateBaseDir}/awf-bounded-query-private-`)).toBe(true);
- expect(paths.root.startsWith('/tmp')).toBe(false);
- expect(paths.ingressRoot.startsWith(`${privateBaseDir}/awf-bounded-query-ingress-`)).toBe(true);
- expect(paths.ingressRoot.startsWith(workDir)).toBe(false);
- expect(paths.seedsDir.startsWith(paths.root)).toBe(true);
- expect(paths.workDir.startsWith(paths.root)).toBe(true);
- expect(paths.controlDir.startsWith(paths.root)).toBe(true);
- expect(paths.auditDir.startsWith(paths.root)).toBe(true);
- expect(paths.seedMapPath.startsWith(paths.root)).toBe(true);
- expect(paths.capabilityPath.startsWith(paths.controlDir)).toBe(true);
- expect(paths.runDir.startsWith(paths.ingressRoot)).toBe(true);
- expect(paths.agentDir.startsWith(paths.ingressRoot)).toBe(true);
- expect(paths.wrapperPath.startsWith(paths.agentDir)).toBe(true);
- });
-
- it('places the socket and skill inside their advertised directories', () => {
- const paths = resolveBoundedQueryPaths(workDir);
-
- expect(paths.socketPath).toBe(path.join(paths.runDir, 'broker.sock'));
- expect(paths.skillPath).toBe(path.join(paths.agentDir, 'SKILL.md'));
- expect(AGENT_SOCKET_PATH.startsWith(`${AGENT_SOCKET_DIR}/`)).toBe(true);
- expect(AGENT_SKILL_PATH.startsWith(`${AGENT_SKILL_DIR}/`)).toBe(true);
- });
-
- it('keeps the run and agent directories separate so the skill can be read-only', () => {
- const paths = resolveBoundedQueryPaths(workDir);
- expect(paths.runDir).not.toBe(paths.agentDir);
- expect(AGENT_SOCKET_DIR).not.toBe(AGENT_SKILL_DIR);
- });
-});
-
-describe('normalizeRepoKey', () => {
- it('lowercases and trims so lookups match GitHub case-insensitivity', () => {
- expect(normalizeRepoKey(' My-Org/My-Repo ')).toBe('my-org/my-repo');
- });
-});
-
-describe('deriveSeedId', () => {
- const runId = 'a'.repeat(32);
-
- it('is deterministic within a run', () => {
- expect(deriveSeedId(runId, 'octo/repo')).toBe(deriveSeedId(runId, 'octo/repo'));
- });
-
- it('ignores repository case, matching the lookup key', () => {
- expect(deriveSeedId(runId, 'Octo/Repo')).toBe(deriveSeedId(runId, 'octo/repo'));
- });
-
- it('differs per repository', () => {
- expect(deriveSeedId(runId, 'octo/a')).not.toBe(deriveSeedId(runId, 'octo/b'));
- });
-
- it('differs across runs, so a seed path is not predictable from the repo name', () => {
- expect(deriveSeedId(runId, 'octo/repo')).not.toBe(deriveSeedId('b'.repeat(32), 'octo/repo'));
- });
-
- it('produces an opaque lowercase hex name with no path separators', () => {
- const seedId = deriveSeedId(runId, 'octo/repo');
- expect(seedId).toMatch(/^[0-9a-f]{32}$/);
- expect(seedId).not.toContain('octo');
- });
-});
-
-describe('generateBoundedQueryRunId', () => {
- it('produces a fresh 128-bit hex identifier', () => {
- const first = generateBoundedQueryRunId();
- const second = generateBoundedQueryRunId();
- expect(first).toMatch(/^[0-9a-f]{32}$/);
- expect(first).not.toBe(second);
- });
-});
diff --git a/src/bounded-query/paths.ts b/src/bounded-query/paths.ts
deleted file mode 100644
index 360c6a6b7..000000000
--- a/src/bounded-query/paths.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import * as crypto from 'crypto';
-import * as path from 'path';
-
-/**
- * Filesystem layout and fixed container paths for the bounded-query subsystem.
- *
- * Broker-private state and the only agent-visible artifacts live in disjoint,
- * run-specific host roots outside `/tmp`. Only the ingress roots are mounted
- * into the primary agent.
- *
- * Layout (host side):
- *
- * ```text
- * /var/tmp/awf-bounded-query-private--/
- * seeds// immutable, read-only repository seed (one per repo)
- * work/ broker-owned per-invocation writable copies
- * control/ broker readiness and other private control state
- * audit/ protected broker diagnostics (never agent-visible)
- * seed-map.json normalized repo -> opaque seed id map (broker input)
- * control/sbx-ingress.json ephemeral sbx ingress capabilities
- *
- * /var/tmp/awf-bounded-query-ingress--/
- * run/ broker Unix socket, shared read-write with the agent
- * skill/ generated SKILL.md and wrapper, shared read-only
- * ```
- */
-export interface BoundedQueryPaths {
- /** Dedicated broker-private host root. Never mounted into the primary agent. */
- root: string;
- /** Immutable per-repository seeds. Mounted read-only into the broker. */
- seedsDir: string;
- /** Broker-owned scratch space for per-invocation writable repo copies. */
- workDir: string;
- /** Broker-private readiness and control state. */
- controlDir: string;
- /** Parent of the only bounded-query artifacts visible to the primary agent. */
- ingressRoot: string;
- /** Directory holding the broker's Unix socket, shared with the agent. */
- runDir: string;
- /** Directory holding agent-visible artifacts (the generated SKILL.md). */
- agentDir: string;
- /** Protected broker diagnostics. Never mounted into the agent or a query. */
- auditDir: string;
- /** Repo → seed map consumed by the broker. */
- seedMapPath: string;
- /** Host path of the broker's Unix socket. */
- socketPath: string;
- /** Host path of the generated skill document. */
- skillPath: string;
- /** Host path of the agent-facing bounded-query executable. */
- wrapperPath: string;
- /** Broker-private path containing ephemeral sbx ingress capabilities. */
- capabilityPath: string;
-}
-
-/** Broker-private state is deliberately outside the agent's broad `/tmp` mount. */
-export const BOUNDED_QUERY_PRIVATE_BASE_DIR = '/var/tmp';
-
-/** Name of the broker's Unix domain socket inside {@link BoundedQueryPaths.runDir}. */
-export const BOUNDED_QUERY_SOCKET_FILENAME = 'broker.sock';
-
-/** Name of the generated skill document inside {@link BoundedQueryPaths.agentDir}. */
-export const BOUNDED_QUERY_SKILL_FILENAME = 'SKILL.md';
-
-/** Name of the generated agent-facing executable. */
-export const BOUNDED_QUERY_WRAPPER_FILENAME = 'bounded-query';
-
-/** Name of the broker-private sbx ingress capability file. */
-export const BOUNDED_QUERY_CAPABILITY_FILENAME = 'sbx-ingress.json';
-
-// ── Fixed container paths ────────────────────────────────────────────────────
-//
-// These are part of the agent-visible contract (the wrapper and the generated
-// skill reference them verbatim) and of the broker contract, so they are
-// centralized here rather than duplicated across shell/JS/TS.
-
-/** Directory the broker socket is mounted at inside the agent container. */
-export const AGENT_SOCKET_DIR = '/run/awf-bounded-query';
-
-/** Full socket path as seen from inside the agent container. */
-export const AGENT_SOCKET_PATH = `${AGENT_SOCKET_DIR}/${BOUNDED_QUERY_SOCKET_FILENAME}`;
-
-/** Directory the generated skill is mounted at inside the agent container. */
-export const AGENT_SKILL_DIR = '/run/awf-bounded-query-skill';
-
-/** Full skill path as seen from inside the agent container. */
-export const AGENT_SKILL_PATH = `${AGENT_SKILL_DIR}/${BOUNDED_QUERY_SKILL_FILENAME}`;
-
-/** Seeds mount point inside the broker container (read-only). */
-export const BROKER_SEEDS_DIR = '/srv/awf/seeds';
-
-/** Per-invocation scratch mount point inside the broker container. */
-export const BROKER_WORK_DIR = '/srv/awf/work';
-
-/** Seed-map mount point inside the broker container (read-only). */
-export const BROKER_SEED_MAP_PATH = '/srv/awf/seed-map.json';
-
-/** Socket directory inside the broker container. */
-export const BROKER_SOCKET_DIR = '/run/awf-bounded-query';
-
-/** Protected diagnostics directory inside the broker container. */
-export const BROKER_AUDIT_DIR = '/var/log/awf-bounded-query';
-
-/** Broker-private control directory inside the broker container. */
-export const BROKER_CONTROL_DIR = '/run/awf-bounded-query-control';
-
-/** Docker socket mount point inside the broker container. */
-export const BROKER_DOCKER_SOCKET_PATH = '/var/run/docker.sock';
-
-/** Writable working directory mounted into each query container. */
-export const QUERY_MOUNT_DIR = '/query';
-
-/** Fixed read-only path the submitted query script is mounted at. */
-export const QUERY_SCRIPT_PATH = '/awf/query-script.py';
-
-/** Derives the private root identity without revealing the work-directory path. */
-function deriveRootIdentity(awfWorkDir: string): string {
- const uid = process.getuid?.() ?? 0;
- const digest = crypto
- .createHash('sha256')
- .update(path.resolve(awfWorkDir), 'utf8')
- .digest('hex')
- .slice(0, 20);
- return `${uid}-${digest}`;
-}
-
-/** Derives every bounded-query path from the AWF work directory. */
-export function resolveBoundedQueryPaths(
- awfWorkDir: string,
- privateBaseDir = BOUNDED_QUERY_PRIVATE_BASE_DIR,
-): BoundedQueryPaths {
- const rootIdentity = deriveRootIdentity(awfWorkDir);
- const root = path.join(privateBaseDir, `awf-bounded-query-private-${rootIdentity}`);
- const ingressRoot = path.join(privateBaseDir, `awf-bounded-query-ingress-${rootIdentity}`);
- const runDir = path.join(ingressRoot, 'run');
- const agentDir = path.join(ingressRoot, 'skill');
- return {
- root,
- seedsDir: path.join(root, 'seeds'),
- workDir: path.join(root, 'work'),
- controlDir: path.join(root, 'control'),
- ingressRoot,
- runDir,
- agentDir,
- auditDir: path.join(root, 'audit'),
- seedMapPath: path.join(root, 'seed-map.json'),
- socketPath: path.join(runDir, BOUNDED_QUERY_SOCKET_FILENAME),
- skillPath: path.join(agentDir, BOUNDED_QUERY_SKILL_FILENAME),
- wrapperPath: path.join(agentDir, BOUNDED_QUERY_WRAPPER_FILENAME),
- capabilityPath: path.join(root, 'control', BOUNDED_QUERY_CAPABILITY_FILENAME),
- };
-}
-
-/**
- * Normalizes an `owner/repo` slug for allowlist lookups.
- *
- * GitHub treats owner and repository names case-insensitively, so the lookup
- * key is lowercased. The *original* spelling is retained separately by the
- * staging phase for clone-URL construction.
- */
-export function normalizeRepoKey(repo: string): string {
- return repo.trim().toLowerCase();
-}
-
-/** Generates the random, run-unique identifier used to derive opaque seed ids. */
-export function generateBoundedQueryRunId(): string {
- return crypto.randomBytes(16).toString('hex');
-}
-
-/**
- * Derives the opaque on-disk seed directory name for a repository.
- *
- * The identifier is a keyed digest of the run id and the normalized repo, so
- * it is stable within a run, unpredictable across runs, and reveals nothing
- * about the repository name to anything that can observe only the path.
- */
-export function deriveSeedId(runId: string, repo: string): string {
- return crypto
- .createHmac('sha256', Buffer.from(runId, 'utf8'))
- .update(normalizeRepoKey(repo), 'utf8')
- .digest('hex')
- .slice(0, 32);
-}
diff --git a/src/bounded-query/preflight.test.ts b/src/bounded-query/preflight.test.ts
deleted file mode 100644
index 473fc34fe..000000000
--- a/src/bounded-query/preflight.test.ts
+++ /dev/null
@@ -1,424 +0,0 @@
-import type { WrapperConfig } from '../types';
-import execa from 'execa';
-import {
- assertPrimaryRuntimeAvailable,
- assertQueryRuntimeAvailable,
- preflightTestHelpers,
- validateBoundedQueryConfig,
-} from './preflight';
-import type { BoundedQueriesConfig } from '../types';
-import type { BoundedQueryRepository } from '../types/bounded-query-options';
-
-jest.mock('execa', () => ({ __esModule: true, default: jest.fn() }));
-const mockExeca = execa as unknown as jest.Mock;
-
-function repo(name: string, sensitivity: BoundedQueryRepository['sensitivity'] = 'internal'): BoundedQueryRepository {
- return { repo: name, sensitivity };
-}
-
-const baseBoundedQueries: BoundedQueriesConfig = {
- enabled: true,
- privateRepos: [repo('octo/private')],
- runtime: 'docker',
- timeout: 30,
- memoryLimit: '512m',
- interpreter: 'python3',
- maxInvocations: 32,
-};
-
-function buildConfig(overrides: Partial = {}, config: Partial = {}): WrapperConfig {
- return {
- workDir: '/tmp/awf-test',
- boundedQueries: { ...baseBoundedQueries, ...overrides },
- ...config,
- } as unknown as WrapperConfig;
-}
-
-const envWithToken: NodeJS.ProcessEnv = { GH_TOKEN: 'ghs_example' };
-
-describe('validateBoundedQueryConfig', () => {
- it('accepts a well-formed enabled configuration', () => {
- expect(validateBoundedQueryConfig(buildConfig(), envWithToken)).toEqual([]);
- });
-
- it('returns no errors when bounded queries are absent or disabled', () => {
- expect(validateBoundedQueryConfig({ workDir: '/tmp/x' } as unknown as WrapperConfig, {})).toEqual([]);
- expect(validateBoundedQueryConfig(buildConfig({ enabled: false }), {})).toEqual([]);
- });
-
- it('rejects an enabled configuration with no repositories', () => {
- const errors = validateBoundedQueryConfig(buildConfig({ privateRepos: [] }), envWithToken);
- expect(errors.join('\n')).toContain('privateRepos is empty');
- });
-
- it.each([
- ['https://github.com/octo/private', 'scheme'],
- ['octo/private?x=1', 'query'],
- ['octo/private#frag', 'fragment'],
- ['octo/*', 'wildcard'],
- ['octo/../etc', 'traversal'],
- ['user:token@octo/private', 'credentials'],
- ['octo/private/extra', 'extra path segment'],
- ])('rejects unsafe repository slug %s (%s)', (repoSlug) => {
- const errors = validateBoundedQueryConfig(buildConfig({ privateRepos: [repo(repoSlug)] }), envWithToken);
- expect(errors.join('\n')).toContain('is not a bare owner/repo slug');
- });
-
- it('rejects case-insensitive duplicates', () => {
- const errors = validateBoundedQueryConfig(
- buildConfig({ privateRepos: [repo('octo/private'), repo('Octo/Private')] }),
- envWithToken,
- );
- expect(errors.join('\n')).toContain('duplicate entry');
- });
-
- it('fails closed for an unsupported query runtime instead of downgrading', () => {
- // Cast to bypass the type check — JSON parsing at runtime can produce any string.
- const errors = validateBoundedQueryConfig(buildConfig({ runtime: 'vmware' as 'docker' }), envWithToken);
- expect(errors.join('\n')).toContain('is not supported');
- expect(errors.join('\n')).toContain('never downgrade');
- });
-
- it('accepts the gvisor query runtime at the configuration layer', () => {
- expect(validateBoundedQueryConfig(buildConfig({ runtime: 'gvisor' }), envWithToken)).toEqual([]);
- });
-
- it('accepts the sbx query runtime at the configuration layer for executable preflight', () => {
- expect(validateBoundedQueryConfig(buildConfig({ runtime: 'sbx' }), envWithToken)).toEqual([]);
- });
-
- it('accepts an sbx primary agent; trusted preflight selects and probes its ingress', () => {
- expect(validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'sbx' }), envWithToken)).toEqual([]);
- });
-
- it('allows a gvisor primary agent runtime (still a Compose service)', () => {
- expect(validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'gvisor' }), envWithToken)).toEqual([]);
- });
-
- it('requires a staging credential on the AWF host', () => {
- const errors = validateBoundedQueryConfig(buildConfig(), {});
- expect(errors.join('\n')).toContain('GH_TOKEN or GITHUB_TOKEN');
- });
-
- it('rejects a TCP Docker host, which a network-less broker cannot reach', () => {
- const errors = validateBoundedQueryConfig(buildConfig({}, { awfDockerHost: 'tcp://localhost:2375' }), envWithToken);
- expect(errors.join('\n')).toContain('require a Unix-socket Docker host');
- });
-
- it('rejects a TCP DOCKER_HOST inherited from the environment', () => {
- const errors = validateBoundedQueryConfig(buildConfig(), {
- ...envWithToken,
- DOCKER_HOST: 'tcp://127.0.0.1:2375',
- });
- expect(errors.join('\n')).toContain('require a Unix-socket Docker host');
- });
-
- it('accepts an explicit Unix-socket Docker host', () => {
- expect(
- validateBoundedQueryConfig(buildConfig({}, { awfDockerHost: 'unix:///run/user/1001/docker.sock' }), envWithToken),
- ).toEqual([]);
- });
-
- it('does not apply Docker-daemon transport requirements to the independent sbx query runtime', () => {
- expect(
- validateBoundedQueryConfig(
- buildConfig({ runtime: 'sbx' }, { awfDockerHost: 'tcp://localhost:2375' }),
- envWithToken,
- ),
- ).toEqual([]);
- });
-
- it('accepts GITHUB_TOKEN as the staging credential', () => {
- expect(validateBoundedQueryConfig(buildConfig(), { GITHUB_TOKEN: 'ghs_x' })).toEqual([]);
- });
-
- it('rejects out-of-range or malformed limits', () => {
- const errors = validateBoundedQueryConfig(
- buildConfig({ timeout: 0, maxInvocations: 0, memoryLimit: 'lots' }),
- envWithToken,
- );
- expect(errors.join('\n')).toContain('timeout must be a positive integer');
- expect(errors.join('\n')).toContain('maxInvocations must be a positive integer');
- expect(errors.join('\n')).toContain('is not a Docker memory limit');
- });
-
- it('accepts a timeout that preserves the final one-minute processing margin (540s)', () => {
- expect(validateBoundedQueryConfig(buildConfig({ timeout: 540 }), envWithToken)).toEqual([]);
- });
-
- it('rejects a timeout that consumes the final timing bucket processing margin', () => {
- const errors = validateBoundedQueryConfig(buildConfig({ timeout: 541 }), envWithToken);
- expect(errors.join('\n')).toContain('timeout must be at most 540 seconds');
- expect(errors.join('\n')).toContain('reserves its final minute');
- });
-
- it('rejects an unsupported interpreter', () => {
- const errors = validateBoundedQueryConfig(
- buildConfig({ interpreter: 'ruby' as unknown as BoundedQueriesConfig['interpreter'] }),
- envWithToken,
- );
- expect(errors.join('\n')).toContain('interpreter "ruby" is not supported');
- });
-});
-
-describe('assertQueryRuntimeAvailable', () => {
- it('requires a reachable Docker daemon for the default query runtime', async () => {
- const runtimeQuery = jest.fn();
- const dockerAvailable = jest.fn().mockResolvedValue(true);
- await expect(
- assertQueryRuntimeAvailable(baseBoundedQueries, runtimeQuery, jest.fn(), dockerAvailable),
- ).resolves.toBeUndefined();
- expect(runtimeQuery).not.toHaveBeenCalled();
- expect(dockerAvailable).toHaveBeenCalledTimes(1);
- });
-
- it('fails closed when the Docker query daemon is unavailable', async () => {
- await expect(
- assertQueryRuntimeAvailable(
- baseBoundedQueries,
- jest.fn(),
- jest.fn(),
- jest.fn().mockResolvedValue(false),
- ),
- ).rejects.toThrow(/Docker daemon.*not available.*never fall back/s);
- });
-
- it('accepts gvisor when runsc is registered with the daemon', async () => {
- const query = jest.fn().mockResolvedValue(true);
- await expect(
- assertQueryRuntimeAvailable({ ...baseBoundedQueries, runtime: 'gvisor' }, query),
- ).resolves.toBeUndefined();
- expect(query).toHaveBeenCalledWith('runsc');
- });
-
- it('fails closed when runsc is unavailable', async () => {
- const query = jest.fn().mockResolvedValue(false);
- await expect(
- assertQueryRuntimeAvailable({ ...baseBoundedQueries, runtime: 'gvisor' }, query),
- ).rejects.toThrow(/runsc.*not available|not available.*fall back/s);
- });
-
- it('reports a caller-provided runtime configuration path', async () => {
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: 'gvisor' },
- jest.fn().mockResolvedValue(false),
- jest.fn(),
- jest.fn(),
- 'enclaves.executors.script.runtime',
- ),
- ).rejects.toThrow(/enclaves\.executors\.script\.runtime "gvisor"/);
- });
-
- it('routes custom and omitted runtimes through their fixed Docker capability checks', async () => {
- const runtimeQuery = jest.fn().mockResolvedValue(true);
- const dockerAvailable = jest.fn().mockResolvedValue(true);
-
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: 'custom' } as unknown as BoundedQueriesConfig,
- runtimeQuery,
- jest.fn(),
- dockerAvailable,
- ),
- ).resolves.toBeUndefined();
- expect(runtimeQuery).toHaveBeenCalledWith('runsc');
-
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: undefined } as unknown as BoundedQueriesConfig,
- runtimeQuery,
- jest.fn(),
- dockerAvailable,
- ),
- ).resolves.toBeUndefined();
- expect(dockerAvailable).toHaveBeenCalledTimes(1);
- });
-
- it('fails closed when custom and omitted runtime capability checks fail', async () => {
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: 'custom' } as unknown as BoundedQueriesConfig,
- jest.fn().mockResolvedValue(false),
- ),
- ).rejects.toThrow(/runsc.*not available|not available.*fall back/s);
-
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: undefined } as unknown as BoundedQueriesConfig,
- jest.fn(),
- jest.fn(),
- jest.fn().mockResolvedValue(false),
- ),
- ).rejects.toThrow(/Docker daemon.*not available.*never fall back/s);
- });
-
- it('fails closed when sbx lacks any mandatory query isolation capability', async () => {
- const query = jest.fn().mockResolvedValue({
- supported: false,
- version: '0.37.1',
- missing: ['sbx create --network=none', 'sbx create --pids-limit'],
- });
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: 'sbx' },
- jest.fn(),
- query,
- ),
- ).rejects.toThrow(/sbx.*blocked.*network=none.*pids-limit.*never fall back/s);
- });
-
- it('accepts sbx only when the complete executable capability proof succeeds', async () => {
- const query = jest.fn().mockResolvedValue({
- supported: true,
- version: '0.37.1',
- missing: [],
- });
- await expect(
- assertQueryRuntimeAvailable(
- { ...baseBoundedQueries, runtime: 'sbx' },
- jest.fn(),
- query,
- ),
- ).resolves.toBeUndefined();
- expect(query).toHaveBeenCalledTimes(1);
- });
-
- it('detects registered runtimes through Docker info', async () => {
- mockExeca.mockResolvedValue({ exitCode: 0, stdout: '{"runc":{},"runsc":{}}' });
- await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(true);
- expect(mockExeca).toHaveBeenCalledWith(
- 'docker',
- ['info', '--format', '{{json .Runtimes}}'],
- expect.objectContaining({ reject: false }),
- );
- });
-
- it('fails closed when Docker info fails or returns malformed JSON', async () => {
- mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
- await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(false);
-
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'not-json' });
- await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(false);
- });
-
- it('reports the current sbx CLI as unsupported when essential controls are absent', async () => {
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' })
- .mockResolvedValueOnce({
- exitCode: 0,
- stdout: '--name --cpus --memory --template',
- })
- .mockResolvedValueOnce({
- exitCode: 0,
- stdout: '--user --workdir',
- });
-
- await expect(preflightTestHelpers.defaultSbxCapabilityQuery()).resolves.toEqual({
- supported: false,
- version: '0.37.1',
- missing: expect.arrayContaining([
- 'pinned AWF Python query template and bootstrap',
- 'sbx create --network=none',
- 'sbx create --pids-limit',
- 'sbx create --disk-limit',
- 'sbx create --ulimit-fsize',
- 'sbx create --mount-target',
- ]),
- });
- });
-
- describe('assertPrimaryRuntimeAvailable', () => {
- it.each([
- [undefined, 'docker'],
- ['docker', 'docker'],
- ['gvisor', 'gvisor'],
- ['runsc', 'gvisor'],
- ['sbx', 'sbx'],
- ['kata', 'custom'],
- ] as const)('accepts an available %s primary backend (%s)', async (runtime, _backend) => {
- await expect(assertPrimaryRuntimeAvailable(
- runtime,
- jest.fn().mockResolvedValue(true),
- jest.fn().mockResolvedValue(true),
- jest.fn().mockResolvedValue(true),
- )).resolves.toBeUndefined();
- });
-
- it.each([
- [undefined, /Docker primary-agent runtime is unavailable/],
- ['docker', /OCI runtime "docker" is not registered.*never fall back/s],
- ['gvisor', /Primary-agent runtime "gvisor".*runsc.*never fall back/s],
- ['sbx', /Primary-agent runtime "sbx" is unavailable.*never fall back/s],
- ['kata', /OCI runtime "kata" is not registered.*never fall back/s],
- ] as const)('fails %s before staging when its primary capability is unavailable', async (runtime, message) => {
- await expect(assertPrimaryRuntimeAvailable(
- runtime,
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- jest.fn().mockResolvedValue(false),
- )).rejects.toThrow(message);
- });
-
- it('checks explicit docker runtime registration instead of Docker daemon availability', async () => {
- const runtimeQuery = jest.fn().mockResolvedValue(true);
- const dockerAvailable = jest.fn().mockResolvedValue(false);
- await expect(assertPrimaryRuntimeAvailable(
- 'docker',
- runtimeQuery,
- dockerAvailable,
- jest.fn().mockResolvedValue(true),
- )).resolves.toBeUndefined();
- expect(runtimeQuery).toHaveBeenCalledWith('docker');
- expect(dockerAvailable).not.toHaveBeenCalled();
- });
- });
-
- it('requires authenticated sbx daemon reachability and preserves only its management environment', async () => {
- const savedToken = process.env.SBX_AUTH_TOKEN;
- const savedProxy = process.env.DOCKER_SANDBOXES_PROXY;
- const savedXdg = process.env.XDG_CONFIG_HOME;
- process.env.SBX_AUTH_TOKEN = 'daemon-credential';
- process.env.DOCKER_SANDBOXES_PROXY = 'http://proxy.invalid';
- process.env.XDG_CONFIG_HOME = '/wrong/config';
- mockExeca
- .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' })
- .mockResolvedValueOnce({ exitCode: 1, stdout: '' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '' })
- .mockResolvedValueOnce({ exitCode: 0, stdout: '' });
-
- try {
- const report = await preflightTestHelpers.defaultSbxCapabilityQuery();
- expect(report.missing).toContain('authenticated sbx CLI/daemon');
- expect(mockExeca).toHaveBeenCalledWith(
- 'sbx',
- ['ls'],
- expect.objectContaining({
- env: expect.objectContaining({ SBX_AUTH_TOKEN: 'daemon-credential' }),
- }),
- );
- const lsOptions = mockExeca.mock.calls.find((call) => call[1][0] === 'ls')?.[2];
- expect(lsOptions.env).not.toHaveProperty('DOCKER_SANDBOXES_PROXY');
- expect(lsOptions.env).not.toHaveProperty('XDG_CONFIG_HOME');
- } finally {
- if (savedToken === undefined) delete process.env.SBX_AUTH_TOKEN;
- else process.env.SBX_AUTH_TOKEN = savedToken;
- if (savedProxy === undefined) delete process.env.DOCKER_SANDBOXES_PROXY;
- else process.env.DOCKER_SANDBOXES_PROXY = savedProxy;
- if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
- else process.env.XDG_CONFIG_HOME = savedXdg;
- }
- });
-
- it('uses authenticated sbx listing for primary availability', async () => {
- mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '[]' });
-
- await expect(preflightTestHelpers.defaultSbxAvailabilityQuery()).resolves.toBe(true);
- expect(mockExeca).toHaveBeenCalledWith(
- 'sbx',
- ['ls'],
- expect.objectContaining({ reject: false }),
- );
- });
-});
diff --git a/src/bounded-query/preflight.ts b/src/bounded-query/preflight.ts
deleted file mode 100644
index caf27d8f7..000000000
--- a/src/bounded-query/preflight.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-import execa from 'execa';
-import type { BoundedQueriesConfig, WrapperConfig } from '../types';
-import {
- defaultDockerAvailabilityQuery,
- defaultDockerRuntimeQuery,
- defaultSbxAvailabilityQuery,
- type DockerAvailabilityQuery,
- type DockerRuntimeQuery,
- type SbxAvailabilityQuery,
-} from '../bounded-execution/runtime-probes';
-import { normalizeRepoKey } from './paths';
-import { MAX_QUERY_TIMEOUT_SECONDS, BOUNDED_QUERY_REPO_PATTERN } from './protocol';
-import { resolveStagingToken } from './staging';
-
-/**
- * Fail-closed preflight for bounded queries.
- *
- * JSON Schema already constrains the *shape* of `boundedQueries`. This module
- * covers everything the schema cannot: credential availability, sandbox
- * runtime availability, and combinations of AWF settings under which bounded
- * queries cannot be exposed securely.
- *
- * Every check here is fatal — a bounded-query run that cannot satisfy its
- * isolation guarantees must abort before the primary agent starts rather than
- * silently downgrading.
- */
-
-/** Query sandbox runtimes with a safe, implemented no-network launcher. */
-const SUPPORTED_QUERY_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']);
-
-/** Docker OCI runtime name required for the `gvisor` query runtime. */
-const GVISOR_DOCKER_RUNTIME = 'runsc';
-
-export type {
- DockerAvailabilityQuery,
- DockerRuntimeQuery,
- SbxAvailabilityQuery,
-} from '../bounded-execution/runtime-probes';
-
-export interface SbxCapabilityReport {
- supported: boolean;
- version?: string;
- missing: string[];
-}
-
-/** Executes the minimum host-side capability proof for the sbx query backend. */
-export type SbxCapabilityQuery = () => Promise;
-
-type RuntimeAvailabilityCase = 'sbx' | 'docker' | 'gvisor' | 'custom' | 'default-docker';
-
-function classifyRuntimeAvailability(runtime: string | undefined): RuntimeAvailabilityCase {
- if (runtime === 'sbx') return 'sbx';
- if (runtime === 'docker') return 'docker';
- if (runtime === 'gvisor' || runtime === 'runsc') return 'gvisor';
- if (runtime) return 'custom';
- return 'default-docker';
-}
-
-interface RuntimeAvailabilityChecks {
- sbx: () => Promise;
- docker: () => Promise;
- gvisor: (runtime: string) => Promise;
- custom: (runtime: string) => Promise;
- defaultDocker: () => Promise;
-}
-
-async function assertRuntimeAvailability(
- runtime: string | undefined,
- checks: RuntimeAvailabilityChecks,
-): Promise {
- const runtimeCase = classifyRuntimeAvailability(runtime);
- switch (runtimeCase) {
- case 'sbx':
- return checks.sbx();
- case 'docker':
- return checks.docker();
- case 'gvisor':
- return checks.gvisor(runtime!);
- case 'custom':
- return checks.custom(runtime!);
- case 'default-docker':
- return checks.defaultDocker();
- default:
- throw new Error(`Unreachable runtime case: ${runtimeCase satisfies never}`);
- }
-}
-
-const SBX_AUDITED_VERSION = '0.37.1';
-const SBX_REQUIRED_CREATE_FLAGS = [
- '--cpus',
- '--memory',
- '--name',
- '--template',
- '--network=none',
- '--pids-limit',
- '--disk-limit',
- '--ulimit-fsize',
- '--mount-target',
-] as const;
-const SBX_REQUIRED_EXEC_FLAGS = ['--user', '--workdir'] as const;
-
-function helpIncludesFlag(help: string, flag: string): boolean {
- const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help);
-}
-
-const defaultSbxCapabilityQuery: SbxCapabilityQuery = async () => {
- const managementEnv = { ...process.env };
- delete managementEnv.DOCKER_SANDBOXES_PROXY;
- delete managementEnv.XDG_CONFIG_HOME;
-
- const run = async (args: string[]): Promise<{ exitCode: number; stdout: string }> => {
- const result = await execa('sbx', args, {
- reject: false,
- timeout: 10_000,
- env: managementEnv,
- });
- return { exitCode: result.exitCode ?? 1, stdout: result.stdout };
- };
-
- let versionResult: { exitCode: number; stdout: string };
- let daemonResult: { exitCode: number; stdout: string };
- let createHelp: { exitCode: number; stdout: string };
- let execHelp: { exitCode: number; stdout: string };
- try {
- [versionResult, daemonResult, createHelp, execHelp] = await Promise.all([
- run(['version']),
- // sbx has no auth-status command; listing is authenticated and non-mutating.
- run(['ls']),
- run(['create', '--help']),
- run(['exec', '--help']),
- ]);
- } catch {
- return { supported: false, missing: ['authenticated sbx CLI/daemon'] };
- }
-
- const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout)?.[1];
- const missing: string[] = ['pinned AWF Python query template and bootstrap'];
- if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) {
- missing.push('authenticated sbx CLI/daemon');
- }
- if (version && version !== SBX_AUDITED_VERSION) {
- missing.push(`audited sbx version ${SBX_AUDITED_VERSION} (found ${version})`);
- }
- for (const flag of SBX_REQUIRED_CREATE_FLAGS) {
- if (createHelp.exitCode !== 0 || !helpIncludesFlag(createHelp.stdout, flag)) {
- missing.push(`sbx create ${flag}`);
- }
- }
- for (const flag of SBX_REQUIRED_EXEC_FLAGS) {
- if (execHelp.exitCode !== 0 || !helpIncludesFlag(execHelp.stdout, flag)) {
- missing.push(`sbx exec ${flag}`);
- }
- }
- return { supported: missing.length === 0, version, missing };
-};
-
-/**
- * Validates everything about a bounded-query configuration that can be decided
- * without touching Docker or the network.
- *
- * @returns human-readable errors; empty when the configuration is acceptable.
- */
-export function validateBoundedQueryConfig(
- config: WrapperConfig,
- env: NodeJS.ProcessEnv = process.env,
-): string[] {
- const boundedQueries = config.boundedQueries;
- if (!boundedQueries?.enabled) return [];
-
- const errors: string[] = [];
-
- if (boundedQueries.privateRepos.length === 0) {
- errors.push('boundedQueries.enabled is true but boundedQueries.privateRepos is empty');
- }
-
- const seenKeys = new Set();
- for (const entry of boundedQueries.privateRepos) {
- const repo = entry.repo;
- if (!BOUNDED_QUERY_REPO_PATTERN.test(repo)) {
- errors.push(
- `boundedQueries.privateRepos entry "${repo}" is not a bare owner/repo slug ` +
- '(no scheme, host, credentials, path traversal, query, fragment, or wildcard)',
- );
- continue;
- }
- const key = normalizeRepoKey(repo);
- if (seenKeys.has(key)) {
- errors.push(`boundedQueries.privateRepos contains a duplicate entry: "${repo}"`);
- }
- seenKeys.add(key);
- }
-
- if (!SUPPORTED_QUERY_RUNTIMES.has(boundedQueries.runtime)) {
- errors.push(
- `boundedQueries.runtime "${boundedQueries.runtime}" is not supported. ` +
- 'AWF has no no-network, per-invocation bounded-query launcher for it, and bounded queries ' +
- 'never downgrade to a weaker runtime. Use "docker", "gvisor", or "sbx".',
- );
- }
-
- if (boundedQueries.interpreter !== 'python3') {
- errors.push(`boundedQueries.interpreter "${boundedQueries.interpreter}" is not supported`);
- }
-
- // Reserve the final minute of the 10-minute response bucket for Docker
- // termination, result validation, container removal, and workspace cleanup.
- // The script timeout cannot consume the entire observable boundary.
- if (!Number.isInteger(boundedQueries.timeout) || boundedQueries.timeout < 1) {
- errors.push('boundedQueries.timeout must be a positive integer number of seconds');
- } else if (boundedQueries.timeout > MAX_QUERY_TIMEOUT_SECONDS) {
- errors.push(
- `boundedQueries.timeout must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` +
- '(the 10-minute response bucket reserves its final minute for termination, validation, and cleanup)',
- );
- }
-
- if (!Number.isInteger(boundedQueries.maxInvocations) || boundedQueries.maxInvocations < 1) {
- errors.push('boundedQueries.maxInvocations must be a positive integer');
- }
-
- if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(boundedQueries.memoryLimit)) {
- errors.push(`boundedQueries.memoryLimit "${boundedQueries.memoryLimit}" is not a Docker memory limit`);
- }
-
- const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST;
- if (boundedQueries.runtime !== 'sbx' && dockerHost && !dockerHost.startsWith('unix://')) {
- errors.push(
- `bounded queries require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` +
- 'The broker runs with network_mode: none so it can only reach the daemon over a bind-mounted ' +
- 'socket, and AWF will not weaken that isolation to reach a TCP daemon.',
- );
- }
-
- if (!resolveStagingToken(env)) {
- errors.push(
- 'bounded queries require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host ' +
- '(it is used only by the trusted staging phase and never reaches the agent, broker, or query)',
- );
- }
-
- return errors;
-}
-
-/**
- * Verifies that the requested query sandbox runtime is actually available.
- *
- * Only reached after {@link validateBoundedQueryConfig} accepted the runtime
- * name, so the only remaining question is daemon support.
- */
-export async function assertQueryRuntimeAvailable(
- boundedQueries: BoundedQueriesConfig,
- queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery,
- querySbxCapabilities: SbxCapabilityQuery = defaultSbxCapabilityQuery,
- queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery,
- runtimeConfigPath = 'boundedQueries.runtime',
-): Promise {
- await assertRuntimeAvailability(boundedQueries.runtime, {
- sbx: async () => {
- const report = await querySbxCapabilities();
- if (!report.supported) {
- throw new Error(
- `${runtimeConfigPath} "sbx" is blocked because the installed sbx runtime cannot enforce all ` +
- `mandatory query-isolation controls: ${report.missing.join(', ')}. ` +
- 'AWF will not launch the configured sandbox and will never fall back to Docker or gVisor.',
- );
- }
- },
- docker: async () => {
- if (!(await queryDockerAvailable())) {
- throw new Error(
- `${runtimeConfigPath} "docker" requires a reachable Docker daemon. It is not available, ` +
- 'and the configured sandbox will never fall back to another runtime.',
- );
- }
- },
- gvisor: async () => {
- if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) {
- throw new Error(
- `${runtimeConfigPath} "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` +
- 'registered with the Docker daemon. It is not available, and the configured sandbox will never fall back ' +
- 'to a weaker runtime.',
- );
- }
- },
- custom: async () => {
- if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) {
- throw new Error(
- `${runtimeConfigPath} "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` +
- 'registered with the Docker daemon. It is not available, and the configured sandbox will never fall back ' +
- 'to a weaker runtime.',
- );
- }
- },
- defaultDocker: async () => {
- if (!(await queryDockerAvailable())) {
- throw new Error(
- `${runtimeConfigPath} "docker" requires a reachable Docker daemon. It is not available, ` +
- 'and the configured sandbox will never fall back to another runtime.',
- );
- }
- },
- });
-}
-
-/** Verifies the primary-agent runtime before bounded-query repository staging. */
-export async function assertPrimaryRuntimeAvailable(
- containerRuntime: string | undefined,
- queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery,
- queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery,
- querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery,
-): Promise {
- await assertRuntimeAvailability(containerRuntime, {
- sbx: async () => {
- if (!(await querySbxAvailable())) {
- throw new Error(
- 'Primary-agent runtime "sbx" is unavailable. Bounded queries abort before staging and never ' +
- 'fall back to a Docker or gVisor primary agent.',
- );
- }
- },
- docker: async () => {
- if (!(await queryDockerRuntime('docker'))) {
- throw new Error(
- 'Primary-agent OCI runtime "docker" is not registered with Docker. ' +
- 'Bounded queries abort before staging and never fall back.',
- );
- }
- },
- gvisor: async (runtime) => {
- if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) {
- throw new Error(
- `Primary-agent runtime "${runtime}" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime. ` +
- 'It is not available, so bounded queries abort before staging and never fall back.',
- );
- }
- },
- custom: async (runtime) => {
- if (!(await queryDockerRuntime(runtime))) {
- throw new Error(
- `Primary-agent OCI runtime "${runtime}" is not registered with Docker. ` +
- 'Bounded queries abort before staging and never fall back.',
- );
- }
- },
- defaultDocker: async () => {
- if (!(await queryDockerAvailable())) {
- throw new Error(
- 'The Docker primary-agent runtime is unavailable. Bounded queries abort before staging and never fall back.',
- );
- }
- },
- });
-}
-
-/** @internal Exported for focused unit tests. */
-// ts-prune-ignore-next
-export const preflightTestHelpers = {
- SUPPORTED_QUERY_RUNTIMES,
- GVISOR_DOCKER_RUNTIME,
- defaultDockerRuntimeQuery,
- defaultDockerAvailabilityQuery,
- defaultSbxAvailabilityQuery,
- defaultSbxCapabilityQuery,
- SBX_AUDITED_VERSION,
- SBX_REQUIRED_CREATE_FLAGS,
- SBX_REQUIRED_EXEC_FLAGS,
-};
diff --git a/src/bounded-query/protocol-parity.test.ts b/src/bounded-query/protocol-parity.test.ts
deleted file mode 100644
index d0110dc8d..000000000
--- a/src/bounded-query/protocol-parity.test.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import * as path from 'path';
-import {
- CANONICAL_ERROR_JSON,
- MAX_ARRAY_LENGTH,
- MAX_ENUM_VALUES,
- MAX_OBJECT_FIELDS,
- MAX_PRIVATE_REPO_LENGTH,
- MAX_QUERY_TIMEOUT_SECONDS,
- MAX_RESULT_BYTES,
- MAX_SCHEMA_BYTES,
- MAX_SCHEMA_DEPTH,
- MAX_SCHEMA_NODES,
- MAX_SCRIPT_BYTES,
- MAX_TUPLE_ITEMS,
- MAX_UNION_VARIANTS,
- QUERY_PROTOCOL_VERSION,
- RESULT_STATUS_BIT_COST,
- FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS,
- BOUNDED_QUERY_REPO_PATTERN,
- TIMING_BUCKETS_MS,
- TIMING_BUCKET_BITS,
- canonicalOkJson,
- canonicalizeSchemaValue,
- ceilLog2BigInt,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
- schemaCardinality,
- strictParseJson,
- validateSchema,
- validateBoundedQueryRequest,
- validateValueAgainstSchema,
- type BoundedQuerySchemaNode,
-} from './protocol';
-import {
- BOUNDED_QUERY_SENSITIVITIES,
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
-} from '../types/bounded-query-options';
-
-/**
- * The broker runs in its own container image and cannot import AWF's
- * TypeScript sources, so
- * `containers/bounded-query/bounded-execution/finite-disclosure.js` restates
- * the entire v2 protocol (finite schema algebra, cardinality/bit
- * charge, strict JSON parsing, request/result validation, canonicalization).
- * This suite runs one shared vector table through *both* implementations and
- * fails the moment they disagree, which is what makes the duplication safe.
- */
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-const brokerProtocol = require(path.join(
- __dirname,
- '..',
- '..',
- 'containers',
- 'bounded-query',
- 'bounded-execution',
- 'finite-disclosure.js',
-));
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-const brokerSensitivity = require(path.join(
- __dirname,
- '..',
- '..',
- 'containers',
- 'bounded-query',
- 'bounded-execution',
- 'sensitivity-policy.js',
-));
-
-const SCHEMA_VECTORS: Array<{ name: string; schema: unknown }> = [
- { name: 'const string', schema: { type: 'const', value: 'ok' } },
- { name: 'const number', schema: { type: 'const', value: 42 } },
- { name: 'const boolean', schema: { type: 'const', value: false } },
- { name: 'const null', schema: { type: 'const', value: null } },
- { name: 'const extra property', schema: { type: 'const', value: 1, extra: true } },
- { name: 'boolean', schema: { type: 'boolean' } },
- { name: 'boolean extra property', schema: { type: 'boolean', extra: true } },
- { name: 'string enum', schema: { type: 'enum', values: ['a', 'b', 'c'] } },
- { name: 'integer enum', schema: { type: 'enum', values: [1, 2, 3] } },
- { name: 'enum duplicate values', schema: { type: 'enum', values: ['a', 'a'] } },
- { name: 'enum mixed types', schema: { type: 'enum', values: ['a', 1] } },
- { name: 'enum empty', schema: { type: 'enum', values: [] } },
- { name: `enum oversized (${MAX_ENUM_VALUES + 1})`, schema: { type: 'enum', values: Array.from({ length: MAX_ENUM_VALUES + 1 }, (_, i) => i) } },
- { name: 'integer bounded', schema: { type: 'integer', minimum: 0, maximum: 255 } },
- { name: 'integer maximum below minimum', schema: { type: 'integer', minimum: 10, maximum: 0 } },
- { name: 'integer non-integer bound', schema: { type: 'integer', minimum: 0.5, maximum: 10 } },
- {
- name: 'object fixed fields',
- schema: {
- type: 'object',
- fields: { ok: { type: 'boolean' }, count: { type: 'integer', minimum: 0, maximum: 3 } },
- },
- },
- { name: 'object empty fields', schema: { type: 'object', fields: {} } },
- {
- name: `object oversized (${MAX_OBJECT_FIELDS + 1} fields)`,
- schema: {
- type: 'object',
- fields: Object.fromEntries(Array.from({ length: MAX_OBJECT_FIELDS + 1 }, (_, i) => [`f${i}`, { type: 'boolean' }])),
- },
- },
- { name: 'object invalid field name', schema: { type: 'object', fields: { 'bad name': { type: 'boolean' } } } },
- { name: 'tuple', schema: { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] } },
- { name: 'tuple empty', schema: { type: 'tuple', items: [] } },
- {
- name: `tuple oversized (${MAX_TUPLE_ITEMS + 1} items)`,
- schema: { type: 'tuple', items: Array.from({ length: MAX_TUPLE_ITEMS + 1 }, () => ({ type: 'boolean' })) },
- },
- { name: 'array fixed length', schema: { type: 'array', items: { type: 'boolean' }, length: 5 } },
- { name: 'array zero length', schema: { type: 'array', items: { type: 'boolean' }, length: 0 } },
- { name: 'array negative length', schema: { type: 'array', items: { type: 'boolean' }, length: -1 } },
- { name: `array oversized length (${MAX_ARRAY_LENGTH + 1})`, schema: { type: 'array', items: { type: 'boolean' }, length: MAX_ARRAY_LENGTH + 1 } },
- {
- name: 'union tagged disjoint',
- schema: {
- type: 'union',
- variants: { a: { type: 'boolean' }, b: { type: 'integer', minimum: 0, maximum: 9 } },
- },
- },
- { name: 'union empty variants', schema: { type: 'union', variants: {} } },
- {
- name: `union oversized (${MAX_UNION_VARIANTS + 1} variants)`,
- schema: {
- type: 'union',
- variants: Object.fromEntries(Array.from({ length: MAX_UNION_VARIANTS + 1 }, (_, i) => [`v${i}`, { type: 'boolean' }])),
- },
- },
- { name: 'union invalid tag', schema: { type: 'union', variants: { '1bad': { type: 'boolean' } } } },
- { name: 'unknown node type', schema: { type: 'string' } },
- { name: 'not an object', schema: 'nope' },
- { name: 'null', schema: null },
- { name: 'array instead of object', schema: [1, 2] },
- { name: 'nested composite', schema: {
- type: 'object',
- fields: {
- status: { type: 'enum', values: ['ok', 'error'] },
- items: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 9 }, length: 3 },
- pair: { type: 'tuple', items: [{ type: 'boolean' }, { type: 'const', value: 'x' }] },
- choice: { type: 'union', variants: { a: { type: 'boolean' }, b: { type: 'boolean' } } },
- },
- } },
- {
- name: `depth exceeded (${MAX_SCHEMA_DEPTH + 1} levels)`,
- schema: (() => {
- let deep: unknown = { type: 'boolean' };
- for (let i = 0; i <= MAX_SCHEMA_DEPTH; i++) deep = { type: 'array', items: deep, length: 1 };
- return deep;
- })(),
- },
- {
- name: 'depth at exact limit',
- schema: (() => {
- let atLimit: unknown = { type: 'boolean' };
- for (let i = 0; i < MAX_SCHEMA_DEPTH; i++) atLimit = { type: 'array', items: atLimit, length: 1 };
- return atLimit;
- })(),
- },
- {
- name: `node count exceeded (${MAX_SCHEMA_NODES} leaves)`,
- schema: { type: 'tuple', items: Array.from({ length: MAX_SCHEMA_NODES }, () => ({ type: 'boolean' })) },
- },
- { name: 'undefined', schema: undefined },
-];
-
-const VALID_SCHEMAS_FOR_VALUE_TESTS: Array<{
- name: string;
- schema: BoundedQuerySchemaNode;
- values: unknown[];
-}> = [
- { name: 'const', schema: { type: 'const', value: 'ok' }, values: ['ok', 'not-ok', 1, null] },
- { name: 'boolean', schema: { type: 'boolean' }, values: [true, false, 1, 'true', null] },
- { name: 'enum', schema: { type: 'enum', values: ['a', 'b'] }, values: ['a', 'b', 'c', 1] },
- {
- name: 'integer',
- schema: { type: 'integer', minimum: 0, maximum: 10 },
- values: [0, 5, 10, 11, -1, 5.5, '5'],
- },
- {
- name: 'object',
- schema: { type: 'object', fields: [{ name: 'ok', schema: { type: 'boolean' } }] },
- values: [{ ok: true }, {}, { ok: true, extra: 1 }, { ok: 'no' }, null, [true]],
- },
- {
- name: 'tuple',
- schema: { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] },
- values: [[true, false], [true], [true, false, true], 'not-an-array'],
- },
- {
- name: 'array',
- schema: { type: 'array', items: { type: 'boolean' }, length: 2 },
- values: [[true, false], [true], [true, false, true]],
- },
- {
- name: 'union',
- schema: {
- type: 'union',
- variants: [
- { tag: 'a', schema: { type: 'boolean' } },
- { tag: 'b', schema: { type: 'integer', minimum: 0, maximum: 9 } },
- ],
- },
- values: [
- { tag: 'a', value: true },
- { tag: 'b', value: 5 },
- { tag: 'b', value: true },
- { tag: 'c', value: true },
- { tag: 'a', value: true, extra: 1 },
- true,
- ],
- },
-];
-
-const REQUEST_VECTORS: Array<{ name: string; request: unknown }> = [
- { name: 'valid request', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'print(1)' } },
- { name: 'not an object', request: 'nope' },
- { name: 'null', request: null },
- { name: 'array', request: [] },
- { name: 'extra control field', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'x', image: 'evil' } },
- { name: 'timeout control field', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'x', timeout: 9999 } },
- { name: 'missing repo', request: { schema: { type: 'boolean' }, script: 'x' } },
- { name: 'url repo', request: { privateRepo: 'https://github.com/octo/private', schema: { type: 'boolean' }, script: 'x' } },
- { name: 'traversal repo', request: { privateRepo: 'octo/../../etc', schema: { type: 'boolean' }, script: 'x' } },
- { name: 'wildcard repo', request: { privateRepo: 'octo/*', schema: { type: 'boolean' }, script: 'x' } },
- { name: 'query repo', request: { privateRepo: 'octo/private?x=1', schema: { type: 'boolean' }, script: 'x' } },
- { name: `oversized repo (> ${MAX_PRIVATE_REPO_LENGTH})`, request: { privateRepo: `octo/${'r'.repeat(MAX_PRIVATE_REPO_LENGTH)}`, schema: { type: 'boolean' }, script: 'x' } },
- { name: 'missing schema', request: { privateRepo: 'octo/private', script: 'x' } },
- { name: 'invalid schema', request: { privateRepo: 'octo/private', schema: { type: 'nope' }, script: 'x' } },
- { name: 'empty script', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: '' } },
- { name: 'non-string script', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 42 } },
- { name: 'oversized script', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'x'.repeat(MAX_SCRIPT_BYTES + 1) } },
- { name: 'script at exact size cap', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'x'.repeat(MAX_SCRIPT_BYTES) } },
-];
-
-const RESULT_VECTORS: Array<{ name: string; schema: BoundedQuerySchemaNode; raw: string }> = [
- { name: 'valid enum result', schema: { type: 'enum', values: ['YES', 'NO', 'UNKNOWN'] }, raw: '{"result":"YES"}' },
- {
- name: 'whitespace tolerant',
- schema: { type: 'object', fields: [{ name: 'result', schema: { type: 'enum', values: ['NO'] } }] },
- raw: ' { "result" : "NO" } ',
- },
- { name: 'malformed JSON', schema: { type: 'boolean' }, raw: 'not json at all' },
- { name: 'duplicate keys', schema: { type: 'object', fields: [{ name: 'result', schema: { type: 'boolean' } }] }, raw: '{"result":true,"result":false}' },
- { name: 'trailing data', schema: { type: 'boolean' }, raw: 'true extra' },
- { name: 'two values concatenated', schema: { type: 'boolean' }, raw: 'true false' },
- { name: 'extra fields', schema: { type: 'object', fields: [{ name: 'ok', schema: { type: 'boolean' } }] }, raw: '{"ok":true,"extra":1}' },
- { name: 'value outside enum', schema: { type: 'enum', values: ['a', 'b'] }, raw: '"c"' },
- { name: 'wrong type', schema: { type: 'boolean' }, raw: '1' },
- { name: 'null value against boolean', schema: { type: 'boolean' }, raw: 'null' },
- { name: 'array instead of object', schema: { type: 'object', fields: [{ name: 'a', schema: { type: 'boolean' } }] }, raw: '["a"]' },
- { name: 'empty string', schema: { type: 'boolean' }, raw: '' },
- { name: 'single-quoted string', schema: { type: 'boolean' }, raw: "'true'" },
- { name: 'unterminated string', schema: { type: 'enum', values: ['x'] }, raw: '"x' },
- { name: 'raw control character', schema: { type: 'enum', values: ['line\nbreak'] }, raw: '"line\nbreak"' },
- { name: 'unicode escape', schema: { type: 'enum', values: ['s'] }, raw: '"\\u0073"' },
- { name: 'invalid hex escape', schema: { type: 'boolean' }, raw: '"\\uZZZZ"' },
- { name: 'invalid escape letter', schema: { type: 'boolean' }, raw: '"\\x41"' },
- { name: 'oversized result', schema: { type: 'enum', values: ['x'.repeat(MAX_RESULT_BYTES)] }, raw: `"${'x'.repeat(MAX_RESULT_BYTES)}"` },
- {
- name: 'nested object matches regardless of key order',
- schema: {
- type: 'object',
- fields: [
- { name: 'a', schema: { type: 'boolean' } },
- { name: 'b', schema: { type: 'boolean' } },
- ],
- },
- raw: '{"b":true,"a":false}',
- },
-];
-
-describe('bounded-query protocol parity (TypeScript vs broker JavaScript)', () => {
- it('exposes identical protocol constants', () => {
- expect(brokerProtocol.QUERY_PROTOCOL_VERSION).toBe(QUERY_PROTOCOL_VERSION);
- expect(brokerProtocol.MAX_SCHEMA_BYTES).toBe(MAX_SCHEMA_BYTES);
- expect(brokerProtocol.MAX_SCHEMA_DEPTH).toBe(MAX_SCHEMA_DEPTH);
- expect(brokerProtocol.MAX_SCHEMA_NODES).toBe(MAX_SCHEMA_NODES);
- expect(brokerProtocol.MAX_ENUM_VALUES).toBe(MAX_ENUM_VALUES);
- expect(brokerProtocol.MAX_OBJECT_FIELDS).toBe(MAX_OBJECT_FIELDS);
- expect(brokerProtocol.MAX_TUPLE_ITEMS).toBe(MAX_TUPLE_ITEMS);
- expect(brokerProtocol.MAX_ARRAY_LENGTH).toBe(MAX_ARRAY_LENGTH);
- expect(brokerProtocol.MAX_UNION_VARIANTS).toBe(MAX_UNION_VARIANTS);
- expect(brokerProtocol.MAX_SCRIPT_BYTES).toBe(MAX_SCRIPT_BYTES);
- expect(brokerProtocol.MAX_RESULT_BYTES).toBe(MAX_RESULT_BYTES);
- expect(brokerProtocol.MAX_PRIVATE_REPO_LENGTH).toBe(MAX_PRIVATE_REPO_LENGTH);
- expect(brokerProtocol.TIMING_BUCKETS_MS).toEqual(TIMING_BUCKETS_MS);
- expect(brokerProtocol.FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS)
- .toBe(FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS);
- expect(brokerProtocol.MAX_QUERY_TIMEOUT_SECONDS).toBe(MAX_QUERY_TIMEOUT_SECONDS);
- expect(brokerProtocol.TIMING_BUCKET_BITS).toBe(TIMING_BUCKET_BITS);
- expect(brokerProtocol.RESULT_STATUS_BIT_COST).toBe(RESULT_STATUS_BIT_COST);
- expect(brokerProtocol.BOUNDED_QUERY_REPO_PATTERN.source).toBe(BOUNDED_QUERY_REPO_PATTERN.source);
- expect(brokerProtocol.CANONICAL_ERROR_JSON).toBe(CANONICAL_ERROR_JSON);
- });
-
- it('keeps broker sensitivity categories and run budgets aligned with host policy', () => {
- expect(brokerSensitivity.BOUNDED_QUERY_SENSITIVITIES).toEqual(BOUNDED_QUERY_SENSITIVITIES);
- expect(brokerSensitivity.BOUNDED_QUERY_SENSITIVITY_RUN_BITS).toEqual(
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS,
- );
- });
-
- it.each(SCHEMA_VECTORS)('agrees on schema validity: $name', ({ schema }) => {
- const ts = validateSchema(schema);
- const js = brokerProtocol.validateSchema(schema);
- expect(js.valid).toBe(ts.valid);
- if (ts.valid && js.valid) {
- expect(js.schema).toEqual(ts.schema);
- }
- });
-
- it.each(SCHEMA_VECTORS.filter((v) => validateSchema(v.schema).valid))(
- 'agrees on cardinality and query-bit charge for valid schema: $name',
- ({ schema }) => {
- const tsValidation = validateSchema(schema);
- const jsValidation = brokerProtocol.validateSchema(schema);
- if (!tsValidation.valid || !jsValidation.valid) throw new Error('unreachable: filtered to valid schemas');
-
- const tsCardinality = schemaCardinality(tsValidation.schema);
- const jsCardinality = brokerProtocol.schemaCardinality(jsValidation.schema);
- expect(jsCardinality).toBe(tsCardinality);
-
- const tsBits = queryBitsForSchema(tsValidation.schema);
- const jsBits = brokerProtocol.queryBitsForSchema(jsValidation.schema);
- expect(jsBits).toBe(tsBits);
- },
- );
-
- it.each(
- VALID_SCHEMAS_FOR_VALUE_TESTS.flatMap(({ name, schema, values }) =>
- values.map((value, index) => ({ name: `${name}[${index}]`, schema, value })),
- ),
- )('agrees on value validation and canonicalization: $name', ({ schema, value }) => {
- const tsValid = validateValueAgainstSchema(schema, value);
- const jsValid = brokerProtocol.validateValueAgainstSchema(schema, value);
- expect(jsValid).toBe(tsValid);
-
- if (tsValid && jsValid) {
- expect(brokerProtocol.canonicalizeSchemaValue(schema, value)).toBe(canonicalizeSchemaValue(schema, value));
- }
- });
-
- it.each(REQUEST_VECTORS)('agrees on request validity: $name', ({ request }) => {
- const ts = validateBoundedQueryRequest(request);
- const js = brokerProtocol.validateBoundedQueryRequest(request);
-
- expect(js.valid).toBe(ts.valid);
- if (!ts.valid && !js.valid) {
- expect(js.errors).toEqual(ts.errors);
- }
- });
-
- it.each(RESULT_VECTORS)('agrees on query output parsing/validation: $name', ({ schema, raw }) => {
- const ts = parseAndValidateQueryOutput(raw, schema);
- const js = brokerProtocol.parseAndValidateQueryOutput(raw, schema);
- expect(js).toEqual(ts);
- });
-
- it('agrees on strict JSON parsing', () => {
- const vectors = ['{"a":1}', '{"a":1,"a":2}', '{"a":1} extra', 'not json', '', '"\\u0073"', '"\\uZZZZ"'];
- for (const raw of vectors) {
- expect(brokerProtocol.strictParseJson(raw)).toEqual(strictParseJson(raw));
- }
- });
-
- it('agrees on ceilLog2BigInt across boundary values', () => {
- for (const n of [0n, 1n, 2n, 3n, 4n, 5n, 8n, 9n, 1024n, 1025n, 2n ** 64n]) {
- expect(brokerProtocol.ceilLog2BigInt(n)).toBe(ceilLog2BigInt(n));
- }
- });
-
- it('agrees on the canonical ok envelope wrapper', () => {
- for (const canonical of ['true', '"ok"', '{"a":1}']) {
- expect(brokerProtocol.canonicalOkJson(canonical)).toBe(canonicalOkJson(canonical));
- }
- });
-});
diff --git a/src/bounded-query/protocol.test.ts b/src/bounded-query/protocol.test.ts
deleted file mode 100644
index 03c9f3169..000000000
--- a/src/bounded-query/protocol.test.ts
+++ /dev/null
@@ -1,744 +0,0 @@
-import {
- CANONICAL_ERROR_JSON,
- MAX_ARRAY_LENGTH,
- MAX_ENUM_VALUES,
- MAX_LITERAL_STRING_BYTES,
- MAX_OBJECT_FIELDS,
- MAX_PRIVATE_REPO_LENGTH,
- MAX_RESULT_BYTES,
- MAX_SCHEMA_BYTES,
- MAX_SCHEMA_DEPTH,
- MAX_SCHEMA_NODES,
- MAX_SCRIPT_BYTES,
- MAX_TUPLE_ITEMS,
- MAX_UNION_VARIANTS,
- QUERY_PROTOCOL_VERSION,
- RESULT_STATUS_BIT_COST,
- BOUNDED_QUERY_REPO_PATTERN,
- TIMING_BUCKETS_MS,
- TIMING_BUCKET_BITS,
- canonicalOkJson,
- canonicalizeSchemaValue,
- ceilLog2BigInt,
- parseAndValidateQueryOutput,
- queryBitsForSchema,
- schemaCardinality,
- strictParseJson,
- validateSchema,
- validateBoundedQueryRequest,
- validateValueAgainstSchema,
- type BoundedQuerySchemaNode,
-} from './protocol';
-import {
- BOUNDED_QUERY_DEFAULTS as EXPORTED_DEFAULTS,
- BOUNDED_QUERY_SENSITIVITIES as EXPORTED_SENSITIVITIES,
- BOUNDED_QUERY_SENSITIVITY_RUN_BITS as EXPORTED_RUN_BITS,
-} from '../types';
-
-describe('protocol constants', () => {
- it('fixes the wire protocol version at 2', () => {
- expect(QUERY_PROTOCOL_VERSION).toBe(2);
- });
-
- it('has exactly six timing buckets and 3 timing bits', () => {
- expect(TIMING_BUCKETS_MS).toEqual([10, 100, 1_000, 10_000, 60_000, 600_000]);
- expect(TIMING_BUCKET_BITS).toBe(3);
- });
-
- it('charges 1 bit for the ok/error distinction', () => {
- expect(RESULT_STATUS_BIT_COST).toBe(1);
- });
-
- it('exposes bounded-query policy constants through the public types barrel', () => {
- expect(EXPORTED_DEFAULTS.timeout).toBe(30);
- expect(EXPORTED_SENSITIVITIES).toEqual(['public', 'internal', 'confidential', 'sealed']);
- expect(EXPORTED_RUN_BITS).toEqual({ public: null, internal: 64, confidential: 8, sealed: 0 });
- });
-});
-
-describe('BOUNDED_QUERY_REPO_PATTERN', () => {
- it.each(['octo/repo', 'octo-org/octo-repo', 'my-org/my.repo-name_2', 'a/b'])(
- 'accepts a valid owner/repo slug: %s',
- (slug) => {
- expect(BOUNDED_QUERY_REPO_PATTERN.test(slug)).toBe(true);
- },
- );
-
- it.each([
- ['a full URL', 'https://github.com/octo/repo'],
- ['a scheme-relative URL', '//github.com/octo/repo'],
- ['a wildcard', 'octo/*'],
- ['dot-traversal repo', 'octo/..'],
- ['single-dot repo', 'octo/.'],
- ['embedded traversal', 'octo/re..po'],
- ['a query string', 'octo/repo?x=1'],
- ['a fragment', 'octo/repo#section'],
- ['an extra path segment', 'octo/repo/extra'],
- ['no owner', '/repo'],
- ['no slash', 'octorepo'],
- ['leading slash owner', '/octo/repo'],
- ['owner starting with dot', './repo'],
- ])('rejects %s', (_label, slug) => {
- expect(BOUNDED_QUERY_REPO_PATTERN.test(slug)).toBe(false);
- });
-});
-
-describe('ceilLog2BigInt', () => {
- it.each([
- [0n, 0],
- [1n, 0],
- [2n, 1],
- [3n, 2],
- [4n, 2],
- [5n, 3],
- [8n, 3],
- [9n, 4],
- [1024n, 10],
- [1025n, 11],
- ])('ceilLog2BigInt(%s) === %s', (n, expected) => {
- expect(ceilLog2BigInt(n)).toBe(expected);
- });
-
- it('handles very large cardinalities without floating-point overflow', () => {
- // 2^100, computed without ever going through a floating-point log.
- const huge = 2n ** 100n;
- expect(ceilLog2BigInt(huge)).toBe(100);
- expect(ceilLog2BigInt(huge + 1n)).toBe(101);
- });
-});
-
-describe('validateSchema', () => {
- it('accepts a const schema', () => {
- const result = validateSchema({ type: 'const', value: 'ok' });
- expect(result).toEqual({ valid: true, schema: { type: 'const', value: 'ok' } });
- });
-
- it('rejects a const schema with extra properties', () => {
- expect(validateSchema({ type: 'const', value: 'ok', extra: 1 }).valid).toBe(false);
- });
-
- it('rejects malformed literal and schema-node shapes', () => {
- expect(validateSchema({ type: 'const' }).valid).toBe(false);
- expect(validateSchema({ type: 'const', value: { arbitrary: 'object' } }).valid).toBe(false);
- expect(validateSchema({ type: 'const', value: 'line\nbreak' }).valid).toBe(false);
- expect(validateSchema({ type: 'enum' }).valid).toBe(false);
- expect(validateSchema({ type: 'enum', values: [undefined] }).valid).toBe(false);
- expect(validateSchema({ type: 'enum', values: [null] }).valid).toBe(true);
- expect(validateSchema({ type: 'integer', minimum: 0 }).valid).toBe(false);
- expect(validateSchema({ type: 'object' }).valid).toBe(false);
- expect(validateSchema({ type: 'object', fields: [] }).valid).toBe(false);
- expect(validateSchema({ type: 'tuple' }).valid).toBe(false);
- expect(validateSchema({ type: 'array', items: { type: 'boolean' } }).valid).toBe(false);
- expect(validateSchema({ type: 'union' }).valid).toBe(false);
- expect(validateSchema({ type: 'union', variants: [] }).valid).toBe(false);
- expect(validateSchema({ type: 'union', variants: { bad: { type: 'unknown' } } }).valid).toBe(false);
- });
-
- it('accepts a boolean schema and rejects extra properties', () => {
- expect(validateSchema({ type: 'boolean' })).toEqual({ valid: true, schema: { type: 'boolean' } });
- expect(validateSchema({ type: 'boolean', extra: 1 }).valid).toBe(false);
- });
-
- it('accepts a unique enum schema of a single JSON type', () => {
- const result = validateSchema({ type: 'enum', values: ['a', 'b', 'c'] });
- expect(result).toEqual({ valid: true, schema: { type: 'enum', values: ['a', 'b', 'c'] } });
- });
-
- it('rejects an enum with duplicate values', () => {
- expect(validateSchema({ type: 'enum', values: ['a', 'a'] }).valid).toBe(false);
- });
-
- it('rejects an enum mixing JSON types', () => {
- expect(validateSchema({ type: 'enum', values: ['a', 1] }).valid).toBe(false);
- });
-
- it('rejects an empty enum', () => {
- expect(validateSchema({ type: 'enum', values: [] }).valid).toBe(false);
- });
-
- it(`rejects an enum exceeding ${MAX_ENUM_VALUES} values`, () => {
- const values = Array.from({ length: MAX_ENUM_VALUES + 1 }, (_, i) => i);
- const result = validateSchema({ type: 'enum', values });
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.join(' ')).toMatch(/at most 4096 entries|4096 bytes/);
- }
- });
-
- it('accepts a moderately sized enum comfortably under both the count and byte caps', () => {
- const values = Array.from({ length: 200 }, (_, i) => i);
- expect(validateSchema({ type: 'enum', values }).valid).toBe(true);
- });
-
- it('accepts a bounded integer schema and rejects maximum < minimum', () => {
- expect(validateSchema({ type: 'integer', minimum: 0, maximum: 10 }).valid).toBe(true);
- expect(validateSchema({ type: 'integer', minimum: 10, maximum: 0 }).valid).toBe(false);
- });
-
- it('rejects a non-integer or unsafe integer bound', () => {
- expect(validateSchema({ type: 'integer', minimum: 0.5, maximum: 10 }).valid).toBe(false);
- expect(validateSchema({ type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER + 1 }).valid).toBe(false);
- });
-
- it('accepts a required fixed object schema', () => {
- const result = validateSchema({
- type: 'object',
- fields: { ok: { type: 'boolean' }, count: { type: 'integer', minimum: 0, maximum: 3 } },
- });
- expect(result.valid).toBe(true);
- });
-
- it('rejects an object schema with zero fields or too many fields', () => {
- expect(validateSchema({ type: 'object', fields: {} }).valid).toBe(false);
- const tooMany: Record = {};
- for (let i = 0; i < MAX_OBJECT_FIELDS + 1; i++) tooMany[`f${i}`] = { type: 'boolean' };
- expect(validateSchema({ type: 'object', fields: tooMany }).valid).toBe(false);
- });
-
- it('rejects an object field name that is not a bounded ASCII identifier', () => {
- expect(validateSchema({ type: 'object', fields: { 'bad name': { type: 'boolean' } } }).valid).toBe(false);
- expect(validateSchema({ type: 'object', fields: { '1bad': { type: 'boolean' } } }).valid).toBe(false);
- });
-
- it('accepts a tuple schema and rejects an empty or oversized one', () => {
- expect(validateSchema({ type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] }).valid).toBe(true);
- expect(validateSchema({ type: 'tuple', items: [] }).valid).toBe(false);
- const tooMany = Array.from({ length: MAX_TUPLE_ITEMS + 1 }, () => ({ type: 'boolean' }));
- expect(validateSchema({ type: 'tuple', items: tooMany }).valid).toBe(false);
- });
-
- it('accepts a fixed-length array schema and rejects an out-of-range length', () => {
- expect(validateSchema({ type: 'array', items: { type: 'boolean' }, length: 3 }).valid).toBe(true);
- expect(validateSchema({ type: 'array', items: { type: 'boolean' }, length: 0 }).valid).toBe(true);
- expect(validateSchema({ type: 'array', items: { type: 'boolean' }, length: -1 }).valid).toBe(false);
- expect(validateSchema({ type: 'array', items: { type: 'boolean' }, length: MAX_ARRAY_LENGTH + 1 }).valid).toBe(
- false,
- );
- });
-
- it('accepts a tagged disjoint union schema and rejects an empty or oversized one', () => {
- expect(
- validateSchema({
- type: 'union',
- variants: { a: { type: 'boolean' }, b: { type: 'integer', minimum: 0, maximum: 1 } },
- }).valid,
- ).toBe(true);
- expect(validateSchema({ type: 'union', variants: {} }).valid).toBe(false);
- const tooMany: Record = {};
- for (let i = 0; i < MAX_UNION_VARIANTS + 1; i++) tooMany[`v${i}`] = { type: 'boolean' };
- expect(validateSchema({ type: 'union', variants: tooMany }).valid).toBe(false);
- });
-
- it('rejects a union tag that is not a bounded ASCII identifier', () => {
- expect(validateSchema({ type: 'union', variants: { '1bad': { type: 'boolean' } } }).valid).toBe(false);
- });
-
- it('rejects an unknown schema node type', () => {
- expect(validateSchema({ type: 'string' }).valid).toBe(false);
- expect(validateSchema({}).valid).toBe(false);
- expect(validateSchema(null).valid).toBe(false);
- expect(validateSchema('not an object').valid).toBe(false);
- expect(validateSchema([1, 2]).valid).toBe(false);
- });
-
- it(`rejects a schema exceeding maximum depth of ${MAX_SCHEMA_DEPTH}`, () => {
- let deep: unknown = { type: 'boolean' };
- for (let i = 0; i <= MAX_SCHEMA_DEPTH; i++) {
- deep = { type: 'array', items: deep, length: 1 };
- }
- expect(validateSchema(deep).valid).toBe(false);
- });
-
- it('accepts a schema at exactly the maximum depth', () => {
- let atLimit: unknown = { type: 'boolean' };
- for (let i = 0; i < MAX_SCHEMA_DEPTH; i++) {
- atLimit = { type: 'array', items: atLimit, length: 1 };
- }
- expect(validateSchema(atLimit).valid).toBe(true);
- });
-
- it(`rejects a schema exceeding ${MAX_SCHEMA_NODES} total nodes`, () => {
- const fields = Object.fromEntries(
- Array.from({ length: 16 }, (_, i) => [
- `f${i}`,
- { type: 'tuple', items: Array.from({ length: 4 }, () => ({ type: 'boolean' })) },
- ]),
- );
- const result = validateSchema({ type: 'object', fields });
- expect(result.valid).toBe(false);
- if (!result.valid) expect(result.errors.join(' ')).toContain('maximum node count');
- });
-
- it(`rejects a const literal string exceeding ${MAX_LITERAL_STRING_BYTES} bytes`, () => {
- expect(validateSchema({ type: 'const', value: 'a'.repeat(MAX_LITERAL_STRING_BYTES) }).valid).toBe(true);
- expect(validateSchema({ type: 'const', value: 'a'.repeat(MAX_LITERAL_STRING_BYTES + 1) }).valid).toBe(false);
- });
-
- it(`rejects a schema serialization exceeding ${MAX_SCHEMA_BYTES} bytes`, () => {
- // An enum of many small distinct strings is a compact way to blow the
- // byte cap without hitting node/field/tuple-count bounds first.
- const values = Array.from({ length: 2000 }, (_, i) => `v${i}`);
- expect(validateSchema({ type: 'enum', values }).valid).toBe(false);
- });
-
- it('rejects a schema that is not JSON-serializable', () => {
- const cyclic: Record = { type: 'boolean' };
- cyclic.self = cyclic;
- expect(validateSchema(cyclic).valid).toBe(false);
- });
-
- it('rejects undefined', () => {
- expect(validateSchema(undefined).valid).toBe(false);
- });
-});
-
-describe('schemaCardinality and queryBitsForSchema', () => {
- it('bounds charge calculation for pathological nested arrays without materializing huge BigInts', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'array',
- length: 64,
- items: {
- type: 'array',
- length: 64,
- items: {
- type: 'array',
- length: 64,
- items: {
- type: 'array',
- length: 64,
- items: { type: 'boolean' },
- },
- },
- },
- };
- expect(queryBitsForSchema(schema)).toBe(1029);
- });
-
- it('computes cardinality 1 for const (0 bits)', () => {
- const schema: BoundedQuerySchemaNode = { type: 'const', value: 'ok' };
- expect(schemaCardinality(schema)).toBe(1n);
- expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 0 + TIMING_BUCKET_BITS);
- });
-
- it('computes cardinality 2 for boolean (1 bit)', () => {
- const schema: BoundedQuerySchemaNode = { type: 'boolean' };
- expect(schemaCardinality(schema)).toBe(2n);
- expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 1 + TIMING_BUCKET_BITS);
- });
-
- it('computes cardinality equal to the enum length', () => {
- const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['a', 'b', 'c', 'd'] };
- expect(schemaCardinality(schema)).toBe(4n);
- expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 2 + TIMING_BUCKET_BITS);
- });
-
- it('computes cardinality as the inclusive integer range size', () => {
- const schema: BoundedQuerySchemaNode = { type: 'integer', minimum: 0, maximum: 255 };
- expect(schemaCardinality(schema)).toBe(256n);
- expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 8 + TIMING_BUCKET_BITS);
- });
-
- it('multiplies cardinality across object fields', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'object',
- fields: [
- { name: 'a', schema: { type: 'boolean' } },
- { name: 'b', schema: { type: 'integer', minimum: 0, maximum: 3 } },
- ],
- };
- // 2 * 4 = 8
- expect(schemaCardinality(schema)).toBe(8n);
- });
-
- it('multiplies cardinality across tuple items', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'tuple',
- items: [{ type: 'boolean' }, { type: 'boolean' }, { type: 'boolean' }],
- };
- expect(schemaCardinality(schema)).toBe(8n);
- });
-
- it('raises item cardinality to the fixed array length', () => {
- const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 10 };
- expect(schemaCardinality(schema)).toBe(1024n);
- });
-
- it('handles a zero-length array as cardinality 1', () => {
- const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 0 };
- expect(schemaCardinality(schema)).toBe(1n);
- });
-
- it('sums cardinality across disjoint union variants', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'union',
- variants: [
- { tag: 'a', schema: { type: 'boolean' } },
- { tag: 'b', schema: { type: 'integer', minimum: 0, maximum: 9 } },
- ],
- };
- // 2 + 10 = 12
- expect(schemaCardinality(schema)).toBe(12n);
- });
-
- it('never overflows even for a schema near the configured bounds', () => {
- // Cardinality far beyond Number.MAX_SAFE_INTEGER — must stay exact as a BigInt.
- const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'integer', minimum: 0, maximum: 65535 }, length: 8 };
- const expected = 65536n ** 8n;
- expect(schemaCardinality(schema)).toBe(expected);
- expect(queryBitsForSchema(schema)).toBe(
- RESULT_STATUS_BIT_COST + ceilLog2BigInt(expected) + TIMING_BUCKET_BITS,
- );
- });
-
- it('charges exactly 4 bits for the cheapest possible schema (const)', () => {
- // 1 (status) + 0 (const) + 3 (timing) = 4 — the floor for every invocation.
- expect(queryBitsForSchema({ type: 'const', value: 1 })).toBe(4);
- });
-});
-
-describe('validateValueAgainstSchema', () => {
- it('validates const by exact value equality', () => {
- expect(validateValueAgainstSchema({ type: 'const', value: 'ok' }, 'ok')).toBe(true);
- expect(validateValueAgainstSchema({ type: 'const', value: 'ok' }, 'not-ok')).toBe(false);
- expect(validateValueAgainstSchema({ type: 'const', value: null }, null)).toBe(true);
- expect(validateValueAgainstSchema({ type: 'const', value: 1 }, 1)).toBe(true);
- expect(validateValueAgainstSchema({ type: 'const', value: 1 }, '1')).toBe(false);
- });
-
- it('validates boolean by strict type', () => {
- const schema: BoundedQuerySchemaNode = { type: 'boolean' };
- expect(validateValueAgainstSchema(schema, true)).toBe(true);
- expect(validateValueAgainstSchema(schema, false)).toBe(true);
- expect(validateValueAgainstSchema(schema, 1)).toBe(false);
- expect(validateValueAgainstSchema(schema, 'true')).toBe(false);
- });
-
- it('validates enum membership only, rejecting unknown members', () => {
- const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['a', 'b'] };
- expect(validateValueAgainstSchema(schema, 'a')).toBe(true);
- expect(validateValueAgainstSchema(schema, 'c')).toBe(false);
- });
-
- it('validates integer range and rejects non-integers', () => {
- const schema: BoundedQuerySchemaNode = { type: 'integer', minimum: 0, maximum: 10 };
- expect(validateValueAgainstSchema(schema, 5)).toBe(true);
- expect(validateValueAgainstSchema(schema, 0)).toBe(true);
- expect(validateValueAgainstSchema(schema, 10)).toBe(true);
- expect(validateValueAgainstSchema(schema, 11)).toBe(false);
- expect(validateValueAgainstSchema(schema, -1)).toBe(false);
- expect(validateValueAgainstSchema(schema, 5.5)).toBe(false);
- });
-
- it('validates fixed object shape: no missing, no extra fields', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'object',
- fields: [{ name: 'ok', schema: { type: 'boolean' } }],
- };
- expect(validateValueAgainstSchema(schema, { ok: true })).toBe(true);
- expect(validateValueAgainstSchema(schema, {})).toBe(false);
- expect(validateValueAgainstSchema(schema, { ok: true, extra: 1 })).toBe(false);
- expect(validateValueAgainstSchema(schema, { ok: 'not-a-bool' })).toBe(false);
- expect(validateValueAgainstSchema(schema, null)).toBe(false);
- expect(validateValueAgainstSchema(schema, [true])).toBe(false);
- });
-
- it('validates fixed-length tuples exactly', () => {
- const schema: BoundedQuerySchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] };
- expect(validateValueAgainstSchema(schema, [true, false])).toBe(true);
- expect(validateValueAgainstSchema(schema, [true])).toBe(false);
- expect(validateValueAgainstSchema(schema, [true, false, true])).toBe(false);
- });
-
- it('validates fixed-length arrays exactly', () => {
- const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 };
- expect(validateValueAgainstSchema(schema, [true, false])).toBe(true);
- expect(validateValueAgainstSchema(schema, [true])).toBe(false);
- expect(validateValueAgainstSchema(schema, [true, false, true])).toBe(false);
- });
-
- it('validates a tagged union: exact tag/value shape, no untagged escape', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'union',
- variants: [
- { tag: 'a', schema: { type: 'boolean' } },
- { tag: 'b', schema: { type: 'integer', minimum: 0, maximum: 9 } },
- ],
- };
- expect(validateValueAgainstSchema(schema, { tag: 'a', value: true })).toBe(true);
- expect(validateValueAgainstSchema(schema, { tag: 'b', value: 5 })).toBe(true);
- expect(validateValueAgainstSchema(schema, { tag: 'b', value: true })).toBe(false);
- expect(validateValueAgainstSchema(schema, { tag: 'c', value: true })).toBe(false);
- expect(validateValueAgainstSchema(schema, { tag: 'a', value: true, extra: 1 })).toBe(false);
- expect(validateValueAgainstSchema(schema, true)).toBe(false);
- });
-});
-
-describe('canonicalizeSchemaValue', () => {
- it('re-serializes const to its declared literal, ignoring the input value', () => {
- expect(canonicalizeSchemaValue({ type: 'const', value: 'ok' }, 'ok')).toBe('"ok"');
- });
-
- it('re-serializes boolean/enum/integer values directly', () => {
- expect(canonicalizeSchemaValue({ type: 'boolean' }, true)).toBe('true');
- expect(canonicalizeSchemaValue({ type: 'enum', values: ['a', 'b'] }, 'b')).toBe('"b"');
- expect(canonicalizeSchemaValue({ type: 'integer', minimum: 0, maximum: 10 }, 7)).toBe('7');
- });
-
- it('re-serializes an object in declared field order regardless of input key order', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'object',
- fields: [
- { name: 'b', schema: { type: 'boolean' } },
- { name: 'a', schema: { type: 'boolean' } },
- ],
- };
- expect(canonicalizeSchemaValue(schema, { a: false, b: true })).toBe('{"b":true,"a":false}');
- });
-
- it('re-serializes tuples and arrays positionally', () => {
- const tuple: BoundedQuerySchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] };
- expect(canonicalizeSchemaValue(tuple, [true, false])).toBe('[true,false]');
-
- const array: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 };
- expect(canonicalizeSchemaValue(array, [false, true])).toBe('[false,true]');
- });
-
- it('re-serializes a tagged union as {"tag":...,"value":...}', () => {
- const schema: BoundedQuerySchemaNode = {
- type: 'union',
- variants: [{ tag: 'a', schema: { type: 'boolean' } }],
- };
- expect(canonicalizeSchemaValue(schema, { tag: 'a', value: true })).toBe('{"tag":"a","value":true}');
- expect(canonicalizeSchemaValue(schema, { tag: 'missing', value: true })).toBe('null');
- });
-});
-
-describe('strictParseJson', () => {
- it('parses valid JSON values', () => {
- expect(strictParseJson('{"a":1}')).toEqual({ value: { a: 1 } });
- expect(strictParseJson('[1,2,3]')).toEqual({ value: [1, 2, 3] });
- expect(strictParseJson('true')).toEqual({ value: true });
- expect(strictParseJson('null')).toEqual({ value: null });
- expect(strictParseJson(' "spaced" ')).toEqual({ value: 'spaced' });
- });
-
- it('rejects duplicate object keys instead of silently keeping the last', () => {
- expect(strictParseJson('{"a":1,"a":2}')).toBeUndefined();
- });
-
- it('rejects trailing data after the value', () => {
- expect(strictParseJson('{"a":1} extra')).toBeUndefined();
- expect(strictParseJson('{"a":1}{}')).toBeUndefined();
- });
-
- it('rejects malformed JSON', () => {
- expect(strictParseJson('not json')).toBeUndefined();
- expect(strictParseJson("{'a':1}")).toBeUndefined();
- expect(strictParseJson('{"a":1')).toBeUndefined();
- expect(strictParseJson('')).toBeUndefined();
- });
-
- it('rejects raw control characters embedded in a string', () => {
- expect(strictParseJson('{"a":"line\nbreak"}')).toBeUndefined();
- });
-
- it.each([
- ['{"a":"s\\"uccess"}', { a: 's"uccess' }],
- ['{"a":"s\\\\uccess"}', { a: 's\\uccess' }],
- ['{"a":"s\\/uccess"}', { a: 's/uccess' }],
- ['{"a":"\\b\\f\\n\\r\\t"}', { a: '\b\f\n\r\t' }],
- ['{"a":"\\u0073"}', { a: 's' }],
- ])('parses standard JSON escapes: %s', (raw, expected) => {
- expect(strictParseJson(raw)).toEqual({ value: expected });
- });
-
- it.each([
- ['0', 0],
- ['-1', -1],
- ['12.5', 12.5],
- ['1e3', 1000],
- ['1E+3', 1000],
- ['1e-3', 0.001],
- ['{}', {}],
- ['[]', []],
- ['false', false],
- ])('parses JSON number and empty-container form %s', (raw, expected) => {
- expect(strictParseJson(raw)).toEqual({ value: expected });
- });
-
- it.each(['01', '-', '1.', '1e', '1e+', '1e999', '{"a" 1}', '{"a":}', '[1', '[1,]', '{"a":1,}'])(
- 'rejects malformed number or container syntax: %s',
- (raw) => {
- expect(strictParseJson(raw)).toBeUndefined();
- },
- );
-
- it('rejects JSON nesting beyond the parser depth bound', () => {
- expect(strictParseJson(`${'['.repeat(40)}0${']'.repeat(40)}`)).toBeUndefined();
- });
-
- it.each(['{"a":"\\x41"}', '{"a":"\\uZZZZ"}', '{"a":"trailing\\\\'])(
- 'rejects invalid escapes: %s',
- (raw) => {
- expect(strictParseJson(raw)).toBeUndefined();
- },
- );
-});
-
-describe('validateBoundedQueryRequest', () => {
- const validRequest = {
- privateRepo: 'octo/repo',
- schema: { type: 'boolean' },
- script: 'print("hello")',
- };
-
- it('accepts a well-formed request', () => {
- const result = validateBoundedQueryRequest(validRequest);
- expect(result).toEqual({
- valid: true,
- request: { privateRepo: 'octo/repo', schema: { type: 'boolean' }, script: 'print("hello")' },
- });
- });
-
- it('rejects non-object requests', () => {
- expect(validateBoundedQueryRequest(null).valid).toBe(false);
- expect(validateBoundedQueryRequest('string').valid).toBe(false);
- expect(validateBoundedQueryRequest([1, 2, 3]).valid).toBe(false);
- });
-
- it('rejects a privateRepo that looks like a URL', () => {
- const result = validateBoundedQueryRequest({ ...validRequest, privateRepo: 'https://github.com/octo/repo' });
- expect(result.valid).toBe(false);
- });
-
- it(`rejects a privateRepo exceeding ${MAX_PRIVATE_REPO_LENGTH} characters`, () => {
- const long = `octo/${'r'.repeat(MAX_PRIVATE_REPO_LENGTH)}`;
- const result = validateBoundedQueryRequest({ ...validRequest, privateRepo: long });
- expect(result.valid).toBe(false);
- });
-
- it('rejects a missing privateRepo', () => {
- const rest: Record = { ...validRequest };
- delete rest.privateRepo;
- expect(validateBoundedQueryRequest(rest).valid).toBe(false);
- });
-
- it('rejects an invalid schema', () => {
- const result = validateBoundedQueryRequest({ ...validRequest, schema: { type: 'nope' } });
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.some((e) => e.startsWith('schema:'))).toBe(true);
- }
- });
-
- it('rejects an empty script', () => {
- expect(validateBoundedQueryRequest({ ...validRequest, script: '' }).valid).toBe(false);
- });
-
- it('rejects a script exceeding the size cap', () => {
- const result = validateBoundedQueryRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES + 1) });
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.some((e) => e.includes('script must be at most'))).toBe(true);
- }
- });
-
- it('accepts a script at exactly the size cap', () => {
- expect(validateBoundedQueryRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES) }).valid).toBe(true);
- });
-
- it('accepts an escape-heavy script at the raw script cap', () => {
- expect(
- validateBoundedQueryRequest({ ...validRequest, script: '\n'.repeat(MAX_SCRIPT_BYTES) }).valid,
- ).toBe(true);
- });
-
- it('rejects unsupported request fields before launch', () => {
- const result = validateBoundedQueryRequest({ ...validRequest, runtime: 'docker' });
- expect(result).toEqual({
- valid: false,
- errors: expect.arrayContaining(['request.runtime is not supported']),
- });
- });
-
- it('rejects a cyclic request through its unsupported field', () => {
- const cyclic: Record = { ...validRequest };
- cyclic.self = cyclic;
- const result = validateBoundedQueryRequest(cyclic);
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors).toEqual(expect.arrayContaining(['request.self is not supported']));
- }
- });
-
- it('aggregates errors across multiple invalid fields', () => {
- const result = validateBoundedQueryRequest({ privateRepo: '', schema: { type: 'nope' }, script: '' });
- expect(result.valid).toBe(false);
- if (!result.valid) {
- expect(result.errors.length).toBeGreaterThan(1);
- }
- });
-});
-
-describe('canonical envelopes', () => {
- it('exposes the exact canonical error JSON', () => {
- expect(CANONICAL_ERROR_JSON).toBe('{"status":"error"}');
- });
-
- it('wraps an already-canonicalized result value into the ok envelope', () => {
- expect(canonicalOkJson('true')).toBe('{"status":"ok","result":true}');
- expect(canonicalOkJson('"ok"')).toBe('{"status":"ok","result":"ok"}');
- });
-});
-
-describe('parseAndValidateQueryOutput', () => {
- const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['success', 'timeout', 'blocked'] };
-
- it('accepts and canonicalizes a valid result', () => {
- expect(parseAndValidateQueryOutput('{"result":"success"}', { type: 'object', fields: [{ name: 'result', schema }] }))
- .toEqual({ ok: true, canonical: '{"result":"success"}' });
- });
-
- it('accepts a bare schema value directly (no envelope object required by the schema itself)', () => {
- expect(parseAndValidateQueryOutput('"success"', schema)).toEqual({ ok: true, canonical: '"success"' });
- });
-
- it('rejects malformed JSON', () => {
- expect(parseAndValidateQueryOutput('not json', schema)).toEqual({ ok: false });
- });
-
- it('rejects a value outside the enum', () => {
- expect(parseAndValidateQueryOutput('"not-a-declared-outcome"', schema)).toEqual({ ok: false });
- });
-
- it(`rejects output exceeding ${MAX_RESULT_BYTES} bytes`, () => {
- const oversized = `"${'x'.repeat(MAX_RESULT_BYTES)}"`;
- expect(parseAndValidateQueryOutput(oversized, { type: 'enum', values: [oversized.slice(1, -1)] })).toEqual({
- ok: false,
- });
- });
-
- it('rejects duplicate-key JSON', () => {
- expect(
- parseAndValidateQueryOutput('{"a":1,"a":2}', { type: 'object', fields: [{ name: 'a', schema: { type: 'boolean' } }] }),
- ).toEqual({ ok: false });
- });
-
- it('rejects an empty string', () => {
- expect(parseAndValidateQueryOutput('', schema)).toEqual({ ok: false });
- });
-
- it('normalizes canonical output regardless of source whitespace/key order', () => {
- const objSchema: BoundedQuerySchemaNode = {
- type: 'object',
- fields: [
- { name: 'a', schema: { type: 'boolean' } },
- { name: 'b', schema: { type: 'boolean' } },
- ],
- };
- expect(parseAndValidateQueryOutput('{ "b" : true , "a" : false }', objSchema)).toEqual({
- ok: true,
- canonical: '{"a":false,"b":true}',
- });
- });
-});
diff --git a/src/bounded-query/protocol.ts b/src/bounded-query/protocol.ts
deleted file mode 100644
index 24397ea73..000000000
--- a/src/bounded-query/protocol.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * Compatibility surface for the bounded-query finite-disclosure protocol.
- *
- * The reusable implementation lives in `bounded-execution`; bounded-query
- * imports remain stable so this foundation refactor does not change its public
- * API or emitted bytes.
- */
-export * from '../bounded-execution/finite-disclosure';
diff --git a/src/bounded-query/query-runner.test.ts b/src/bounded-query/query-runner.test.ts
deleted file mode 100644
index 6f18f74ae..000000000
--- a/src/bounded-query/query-runner.test.ts
+++ /dev/null
@@ -1,450 +0,0 @@
-import * as path from 'path';
-import { preflightTestHelpers } from './preflight';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const {
- createQueryRunner,
- deriveQueryContainerSpec,
-} = require(path.join(brokerDir, 'query-runner.js'));
-const { DockerQueryRunner } = require(path.join(brokerDir, 'docker-query-runner.js'));
-const { GvisorQueryRunner } = require(path.join(brokerDir, 'gvisor-query-runner.js'));
-const { SbxQueryRunner } = require(path.join(brokerDir, 'sbx-query-runner.js'));
-const {
- deriveSbxQuerySpec,
- SBX_QUERY_TEMPLATE,
-} = require(path.join(brokerDir, 'sbx-query-runner-spec.js'));
-const {
- probeSbxCapabilities,
- REQUIRED_CREATE_FLAGS,
- REQUIRED_EXEC_FLAGS,
- REQUIRED_HARD_ISOLATION_FLAGS,
-} = require(path.join(brokerDir, 'sbx-capability-probe.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-interface DockerResult {
- exitCode: number;
- timedOut: boolean;
- stdout: string;
- stderr: string;
-}
-
-const ok = (overrides: Partial = {}): DockerResult => ({
- exitCode: 0,
- timedOut: false,
- stdout: '',
- stderr: '',
- ...overrides,
-});
-
-const config = {
- queryBackend: 'docker',
- workDir: '/srv/awf/work',
- hostWorkDir: '/daemon/private/work',
- sbxWorkDir: '/sbx-daemon/private/work',
- queryMountDir: '/query',
- queryScriptPath: '/awf/query-script.py',
- querySeccompPath: '/opt/awf/query-seccomp.json',
- queryImage: 'ghcr.io/example/bounded-query:1',
- memoryLimit: '256m',
- timeoutSeconds: 30,
- queryUid: 65534,
- queryGid: 65534,
-};
-
-function createDocker(
- handler: (args: readonly string[]) => DockerResult | Promise = () => ok(),
-) {
- const calls: string[][] = [];
- return {
- calls,
- client: {
- runDocker: async (args: readonly string[]) => {
- calls.push([...args]);
- return handler(args);
- },
- },
- };
-}
-
-function createSbx(
- handler: (args: readonly string[]) => DockerResult | Promise = () => ok(),
-) {
- const calls: string[][] = [];
- return {
- calls,
- client: {
- runSbx: async (args: readonly string[]) => {
- calls.push([...args]);
- return handler(args);
- },
- },
- };
-}
-
-describe('trusted bounded-query runner contract', () => {
- it('derives a frozen launch specification with no request-controlled surface', () => {
- const maliciousRequest = {
- image: 'attacker/image',
- command: ['sh'],
- mounts: ['/etc:/host'],
- runtime: 'runc',
- env: { LEAK: '1' },
- };
- const spec = deriveQueryContainerSpec({
- config,
- runId: 'abcd1234',
- invocationId: '0123456789abcdef',
- runtimeName: undefined,
- request: maliciousRequest,
- });
-
- expect(Object.isFrozen(spec)).toBe(true);
- expect(Object.isFrozen(spec.launchArgs)).toBe(true);
- expect(spec.launchArgs.join(' ')).not.toContain('attacker');
- expect(spec.launchArgs.join(' ')).not.toContain('/etc:/host');
- expect(spec.launchArgs.join(' ')).not.toContain('LEAK');
- expect(spec.launchArgs.slice(-3)).toEqual([
- '--entrypoint',
- '/usr/local/bin/run-query',
- config.queryImage,
- ]);
- expect(spec.launchArgs.filter((value: string) => value === '-v')).toHaveLength(3);
- });
-
- it('creates a distinct named sandbox for every invocation', () => {
- const first = deriveQueryContainerSpec({
- config,
- runId: 'abcd1234',
- invocationId: '1111111111111111',
- });
- const second = deriveQueryContainerSpec({
- config,
- runId: 'abcd1234',
- invocationId: '2222222222222222',
- });
-
- expect(first.containerName).not.toBe(second.containerName);
- expect(first.launchArgs).toContain(first.containerName);
- expect(second.launchArgs).toContain(second.containerName);
- expect(first.launchArgs.join(' ')).toContain('/1111111111111111/repo:');
- expect(second.launchArgs.join(' ')).toContain('/2222222222222222/repo:');
- });
-
- it('selects Docker default runtime versus the fixed runsc runtime explicitly', () => {
- const dockerRunner = createQueryRunner(config, { docker: createDocker().client });
- const gvisorRunner = createQueryRunner(
- { ...config, queryBackend: 'gvisor' },
- { docker: createDocker().client },
- );
-
- expect(dockerRunner).toBeInstanceOf(DockerQueryRunner);
- expect(gvisorRunner).toBeInstanceOf(GvisorQueryRunner);
- expect(dockerRunner.spec('abcd1234', '1111111111111111').launchArgs).not.toContain('--runtime');
- const gvisorArgs = gvisorRunner.spec('abcd1234', '1111111111111111').launchArgs;
- expect(gvisorArgs.slice(gvisorArgs.indexOf('--runtime'), gvisorArgs.indexOf('--runtime') + 2))
- .toEqual(['--runtime', 'runsc']);
- });
-
- it('selects the independent sbx runner without reusing a Docker adapter', () => {
- const { client } = createSbx();
- const runner = createQueryRunner(
- { ...config, queryBackend: 'sbx' },
- { sbx: client, docker: { runDocker: () => Promise.reject(new Error('must not run')) } },
- );
- expect(runner).toBeInstanceOf(SbxQueryRunner);
- });
-
- it('derives a unique immutable sbx VM spec only from trusted identifiers', () => {
- const runId = 'abcd1234abcd1234abcd1234abcd1234';
- const maliciousRequest = {
- name: 'awf-agent-primary',
- template: 'attacker/image',
- command: ['sh'],
- paths: ['/etc'],
- network: 'host',
- environment: { GH_TOKEN: 'secret' },
- };
- const first = deriveSbxQuerySpec({
- config,
- runId,
- invocationId: '111111111111111111111111',
- request: maliciousRequest,
- });
- const second = deriveSbxQuerySpec({
- config,
- runId,
- invocationId: '222222222222222222222222',
- request: maliciousRequest,
- });
-
- expect(Object.isFrozen(first)).toBe(true);
- expect(Object.isFrozen(first.createArgs)).toBe(true);
- expect(first.sandboxName).not.toBe(second.sandboxName);
- expect(first.sandboxName).toMatch(/^awf-query-sbx-/);
- expect(first.sandboxName).not.toContain('awf-agent');
- expect(first.createArgs).toContain(SBX_QUERY_TEMPLATE);
- for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) {
- expect(first.createArgs).toContain(flag);
- }
- expect(first.createArgs.join(' ')).not.toMatch(/attacker|\/etc|GH_TOKEN|secret|network host/);
- expect(first.runPrefix).toBe(`awf-query-sbx-${runId}-`);
- expect(first.createArgs.join(' ')).toContain(
- '/sbx-daemon/private/work/111111111111111111111111/repo:/awf/seed:ro',
- );
- expect(second.createArgs.join(' ')).toContain(
- '/sbx-daemon/private/work/222222222222222222222222/repo:/awf/seed:ro',
- );
- expect(first.createArgs.join(' ')).not.toContain(config.hostWorkDir);
- expect(first.execArgs).toContain('65534:65534');
- expect(first.execArgs).toContain('/query');
- expect(first.execArgs.slice(-1)).toEqual(['/usr/local/bin/awf-run-query']);
- });
-
- it('blocks the audited sbx CLI because hard isolation controls are absent', async () => {
- const { client } = createSbx((args) => {
- if (args[0] === 'version') return ok({ stdout: 'Docker Sandboxes v0.37.1' });
- if (args[0] === 'create') return ok({ stdout: '--name --cpus --memory --template' });
- if (args[0] === 'exec') return ok({ stdout: '--user --workdir' });
- return ok();
- });
-
- const report = await probeSbxCapabilities(client);
- expect(report.supported).toBe(false);
- for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) {
- expect(report.missing).toContain(`sbx create ${flag}`);
- }
- const runner = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client });
- await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s);
- });
-
- it('blocks sbx when the CLI exists but its authenticated daemon is unavailable', async () => {
- const { client } = createSbx((args) => {
- if (args[0] === 'version') return ok({ stdout: 'Docker Sandboxes v0.37.1' });
- if (args[0] === 'ls') return ok({ exitCode: 1, stderr: 'not authenticated' });
- if (args[0] === 'create') {
- return ok({ stdout: [...REQUIRED_CREATE_FLAGS, ...REQUIRED_HARD_ISOLATION_FLAGS].join(' ') });
- }
- if (args[0] === 'exec') return ok({ stdout: REQUIRED_EXEC_FLAGS.join(' ') });
- return ok();
- });
-
- const report = await probeSbxCapabilities(client);
- expect(report.supported).toBe(false);
- expect(report.missing).toContain('authenticated sbx CLI/daemon');
- });
-
- it('keeps host and broker sbx capability contracts byte-for-byte aligned', () => {
- expect(preflightTestHelpers.SBX_REQUIRED_CREATE_FLAGS).toEqual([
- ...REQUIRED_CREATE_FLAGS,
- ...REQUIRED_HARD_ISOLATION_FLAGS,
- ]);
- expect(preflightTestHelpers.SBX_REQUIRED_EXEC_FLAGS).toEqual(REQUIRED_EXEC_FLAGS);
- });
-
- it('always force-removes a uniquely named sbx VM before returning', async () => {
- const runId = 'abcd1234abcd1234abcd1234abcd1234';
- const invocationId = '111111111111111111111111';
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: '' });
- return ok();
- });
- const runner = createQueryRunner(
- { ...config, queryBackend: 'sbx' },
- {
- sbx: client,
- probe: async () => ({ supported: true, missing: [] }),
- files: { mkdirSync: jest.fn() },
- },
- );
- await runner.assertAvailable();
- await expect(runner.runQueryContainer({
- runId,
- invocationId,
- })).resolves.toMatchObject({ exitCode: 0, timedOut: false });
-
- const name = runner.spec(runId, invocationId).sandboxName;
- expect(calls.find((args) => args[0] === 'create')).toContain(name);
- expect(calls.find((args) => args[0] === 'exec')).toContain(name);
- expect(calls).toContainEqual(['stop', name]);
- expect(calls).toContainEqual(['rm', '--force', name]);
- expect(calls[calls.length - 1]).toEqual(['rm', '--force', name]);
- });
-
- it('reconciles only sbx VMs with the current trusted run prefix', async () => {
- const runId = 'abcd1234abcd1234abcd1234abcd1234';
- const staleName = `awf-query-sbx-${runId}-111111111111111111111111`;
- const { calls, client } = createSbx((args) => {
- if (args[0] === 'ls' && args[1] === '--json') {
- return ok({
- stdout: JSON.stringify([
- { name: staleName },
- { name: 'awf-query-sbx-other-run' },
- { name: 'awf-agent-primary' },
- ]),
- });
- }
- return ok();
- });
- const runner = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client });
- await runner.reconcileRun(runId);
-
- expect(calls).toContainEqual(['stop', staleName]);
- expect(calls).toContainEqual(['rm', '--force', staleName]);
- expect(calls.join(' ')).not.toContain('awf-query-sbx-other-run');
- expect(calls.join(' ')).not.toContain('awf-agent-primary');
- });
-
- it('rejects malformed sbx inventory rather than accepting cleanup injection', async () => {
- const { client } = createSbx((args) => (
- args[0] === 'ls' ? ok({ stdout: '[{"name":"--all"}]' }) : ok()
- ));
- const runner = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client });
- await expect(
- runner.reconcileRun('abcd1234abcd1234abcd1234abcd1234'),
- ).rejects.toThrow(/invalid sandbox name/);
- });
-
- it('fails closed for unknown and unavailable runtimes', async () => {
- expect(() => createQueryRunner({ ...config, queryBackend: 'runc' })).toThrow(
- /Unsupported bounded-query backend/,
- );
-
- const { client } = createDocker((args) => {
- if (args[0] === 'info') return ok({ stdout: 'runc\n' });
- return ok();
- });
- const runner = createQueryRunner({ ...config, queryBackend: 'gvisor' }, { docker: client });
- await expect(runner.assertAvailable()).rejects.toThrow(/runsc OCI runtime; no fallback/);
- });
-
- it('detects runsc from the bounded Docker runtime-name listing', async () => {
- const { calls, client } = createDocker((args) => {
- if (args[0] === 'info') return ok({ stdout: 'io.containerd.runc.v2\nrunc\nrunsc\n' });
- return ok();
- });
- const runner = createQueryRunner({ ...config, queryBackend: 'gvisor' }, { docker: client });
-
- await expect(runner.assertAvailable()).resolves.toBeUndefined();
- expect(calls).toContainEqual([
- 'info',
- '--format',
- '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}',
- ]);
- });
-
- it.each([
- ['timeout', false],
- ['error', true],
- ])('removes the labelled invocation after a %s', async (_case, launchThrows) => {
- const containerId = 'a'.repeat(64);
- const { calls, client } = createDocker((args) => {
- if (args[0] === 'run') {
- if (launchThrows) throw new Error('daemon disconnected');
- return ok({ exitCode: 137, timedOut: true });
- }
- if (args[0] === 'ps') return ok({ stdout: `${containerId}\n` });
- return ok();
- });
- const runner = createQueryRunner(config, { docker: client });
- const run = runner.runQueryContainer({
- runId: 'abcd1234',
- invocationId: '1111111111111111',
- timeoutMs: 100,
- });
-
- if (_case === 'error') {
- await expect(run).rejects.toThrow('daemon disconnected');
- } else {
- await expect(run).resolves.toMatchObject({ timedOut: true });
- }
- const list = calls.find((args) => args[0] === 'ps');
- expect(list).toEqual([
- 'ps', '-aq',
- '--filter', 'label=awf.bounded-query.run=abcd1234',
- '--filter', 'label=awf.bounded-query.invocation=1111111111111111',
- ]);
- expect(calls).toContainEqual(['rm', '-f', containerId]);
- });
-
- it('preserves a successful stopped-container result when cleanup reports it already absent', async () => {
- const { client } = createDocker((args) => {
- if (args[0] === 'run') return ok();
- if (args[0] === 'ps') return ok({ stdout: 'c'.repeat(64) });
- if (args[0] === 'rm') return ok({ exitCode: 1, stderr: 'No such container' });
- return ok();
- });
- const runner = createQueryRunner(config, { docker: client });
-
- await expect(runner.runQueryContainer({
- runId: 'abcd1234',
- invocationId: '1111111111111111',
- })).resolves.toMatchObject({ exitCode: 0, timedOut: false });
- });
-
- it('serializes interruption reconciliation with per-invocation cleanup', async () => {
- const events: string[] = [];
- let releaseList: (() => void) | undefined;
- const firstList = new Promise((resolve) => {
- releaseList = resolve;
- });
- let listCount = 0;
- const { client } = createDocker(async (args) => {
- if (args[0] !== 'ps') return ok();
- listCount += 1;
- events.push(`list-${listCount}-start`);
- if (listCount === 1) await firstList;
- events.push(`list-${listCount}-end`);
- return ok();
- });
- const runner = createQueryRunner(config, { docker: client });
-
- const invocationCleanup = runner.cleanupInvocation('abcd1234', '1111111111111111');
- const reconciliation = runner.reconcileRun('abcd1234');
- await Promise.resolve();
- expect(events).toEqual(['list-1-start']);
- releaseList?.();
- await Promise.all([invocationCleanup, reconciliation]);
- expect(events).toEqual([
- 'list-1-start',
- 'list-1-end',
- 'list-2-start',
- 'list-2-end',
- ]);
- });
-
- it('reconciles interruption leftovers by run label without touching unrelated containers', async () => {
- const abandonedId = 'b'.repeat(64);
- const { calls, client } = createDocker((args) => (
- args[0] === 'ps' ? ok({ stdout: abandonedId }) : ok()
- ));
- const runner = createQueryRunner(config, { docker: client });
-
- await runner.reconcileRun('abcd1234');
-
- expect(calls[0]).toEqual([
- 'ps', '-aq',
- '--filter', 'label=awf.bounded-query.run=abcd1234',
- ]);
- expect(calls[1]).toEqual(['rm', '-f', abandonedId]);
- });
-
- it('rejects daemon output that could become an untrusted cleanup argument', async () => {
- const { client } = createDocker((args) => (
- args[0] === 'ps' ? ok({ stdout: '--force' }) : ok()
- ));
- const runner = createQueryRunner(config, { docker: client });
-
- await expect(runner.reconcileRun('abcd1234')).rejects.toThrow(/invalid.*container id/);
- });
-});
-
-const realSbxCapabilityTest = process.env.AWF_TEST_REAL_SBX_QUERY_CAPABILITIES === '1' ? it : it.skip;
-realSbxCapabilityTest('probes the installed sbx CLI/daemon without launching a query VM', async () => {
- const report = await probeSbxCapabilities();
- expect(report).toEqual(expect.objectContaining({
- supported: expect.any(Boolean),
- auditedVersion: '0.37.1',
- missing: expect.any(Array),
- }));
-});
diff --git a/src/bounded-query/query-seccomp.test.ts b/src/bounded-query/query-seccomp.test.ts
deleted file mode 100644
index a5eb5b836..000000000
--- a/src/bounded-query/query-seccomp.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-
-/**
- * Invariants for the query sandbox seccomp profile.
- *
- * `containers/bounded-query/query-seccomp.json` is derived from the agent
- * profile minus the syscalls a stdlib-only python3 query never needs. These
- * assertions keep the derivation honest if either profile is regenerated.
- */
-
-const CONTAINERS = path.join(__dirname, '..', '..', 'containers');
-
-interface SeccompProfile {
- defaultAction: string;
- architectures: string[];
- syscalls: Array<{ names: string[]; action: string }>;
-}
-
-function load(file: string): SeccompProfile {
- return JSON.parse(fs.readFileSync(file, 'utf8')) as SeccompProfile;
-}
-
-const queryProfile = load(path.join(CONTAINERS, 'bounded-query', 'query-seccomp.json'));
-const agentProfile = load(path.join(CONTAINERS, 'agent', 'seccomp-profile.json'));
-
-function allowedNames(profile: SeccompProfile): Set {
- const names = new Set();
- for (const block of profile.syscalls) {
- if (block.action !== 'SCMP_ACT_ALLOW') continue;
- for (const name of block.names) names.add(name);
- }
- return names;
-}
-
-describe('query seccomp profile', () => {
- it('denies by default', () => {
- expect(queryProfile.defaultAction).toBe('SCMP_ACT_ERRNO');
- });
-
- it('covers the same architectures as the agent profile', () => {
- expect(queryProfile.architectures).toEqual(agentProfile.architectures);
- });
-
- it('allows no syscall the agent profile does not already allow', () => {
- const agentAllowed = allowedNames(agentProfile);
- const extra = [...allowedNames(queryProfile)].filter((name) => !agentAllowed.has(name));
- expect(extra).toEqual([]);
- });
-
- it.each([
- 'chroot',
- 'mount',
- 'umount2',
- 'pivot_root',
- 'unshare',
- 'setns',
- 'ptrace',
- 'process_vm_readv',
- 'process_vm_writev',
- 'bpf',
- 'perf_event_open',
- 'init_module',
- 'finit_module',
- 'delete_module',
- 'kexec_load',
- 'reboot',
- 'add_key',
- 'request_key',
- 'keyctl',
- 'mknod',
- 'mknodat',
- 'name_to_handle_at',
- 'open_by_handle_at',
- 'userfaultfd',
- ])('never allows %s', (syscall) => {
- expect(allowedNames(queryProfile).has(syscall)).toBe(false);
- });
-
- it('explicitly denies those syscalls in addition to the default action', () => {
- const denied = new Set(
- queryProfile.syscalls
- .filter((block) => block.action === 'SCMP_ACT_ERRNO')
- .flatMap((block) => block.names),
- );
- expect(denied.has('chroot')).toBe(true);
- expect(denied.has('ptrace')).toBe(true);
- expect(denied.has('open_by_handle_at')).toBe(true);
- });
-
- it('still allows the syscalls a python3 interpreter needs to start and read files', () => {
- const allowed = allowedNames(queryProfile);
- for (const syscall of ['execve', 'openat', 'read', 'write', 'mmap', 'brk', 'getdents64', 'exit_group']) {
- expect(allowed.has(syscall)).toBe(true);
- }
- });
-});
diff --git a/src/bounded-query/runtime-matrix.test.ts b/src/bounded-query/runtime-matrix.test.ts
deleted file mode 100644
index 18fb03a20..000000000
--- a/src/bounded-query/runtime-matrix.test.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import {
- BOUNDED_QUERY_RUNTIME_BACKENDS,
- evaluateBoundedQueryRuntimeCombination,
- resolveBoundedQueryPrimaryBackend,
- serializeBoundedQueryRuntimeTelemetry,
- type BoundedQueryPrimaryBackend,
- type BoundedQueryRuntimeCapabilities,
-} from './runtime-matrix';
-
-/* eslint-disable @typescript-eslint/no-require-imports */
-const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker');
-const { createBroker } = require(path.join(brokerDir, 'broker.js'));
-const { createQueryRunner } = require(path.join(brokerDir, 'query-runner.js'));
-const { createRuntimeTelemetry } = require(path.join(brokerDir, 'runtime-telemetry.js'));
-/* eslint-enable @typescript-eslint/no-require-imports */
-
-const CANONICAL_ERROR = '{"status":"error"}';
-const CANONICAL_OK = '{"status":"ok","result":true}';
-const BOOLEAN_SCHEMA = { type: 'boolean' };
-const PRIMARY_BACKENDS = BOUNDED_QUERY_RUNTIME_BACKENDS;
-const QUERY_BACKENDS = BOUNDED_QUERY_RUNTIME_BACKENDS;
-
-const deterministicCapabilities: BoundedQueryRuntimeCapabilities = {
- primary: {
- docker: 'supported',
- gvisor: 'supported',
- sbx: 'supported',
- },
- query: {
- docker: 'supported',
- gvisor: 'supported',
- sbx: 'blocked',
- },
-};
-
-const combinations = PRIMARY_BACKENDS.flatMap((primaryBackend) =>
- QUERY_BACKENDS.map((queryBackend) => ({ primaryBackend, queryBackend })));
-const executableCombinations = combinations.filter(({ primaryBackend, queryBackend }) =>
- evaluateBoundedQueryRuntimeCombination(primaryBackend, queryBackend, deterministicCapabilities).supported);
-const blockedCombinations = combinations.filter(({ primaryBackend, queryBackend }) =>
- !evaluateBoundedQueryRuntimeCombination(primaryBackend, queryBackend, deterministicCapabilities).supported);
-
-interface HarnessOptions {
- maxInvocations?: number;
- sensitivity?: 'public' | 'internal' | 'confidential';
- output?: string;
- runnerResult?: { exitCode: number; timedOut: boolean };
- processingMs?: number;
-}
-
-async function invoke(
- broker: { handle: (request: unknown, respond: (json: string) => void) => Promise },
- request: unknown,
-): Promise {
- let response = '';
- await broker.handle(request, (json: string) => {
- response = json;
- });
- return response;
-}
-
-function createHarness(
- primaryBackend: BoundedQueryPrimaryBackend,
- queryBackend: 'docker' | 'gvisor',
- options: HarnessOptions = {},
-) {
- const outputs = new Map();
- const launches: Array> = [];
- const destroyed: string[] = [];
- const telemetry: Array