diff --git a/.github/workflows/smoke-bounded-agents-gvisor.lock.yml b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml
new file mode 100644
index 000000000..01a081fd5
--- /dev/null
+++ b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml
@@ -0,0 +1,1382 @@
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"815e1a6139f2221439fefb96de95038a0cbfa55a948673004935913824ef04be","body_hash":"245ddc5ea0c938d471a36a6fd125e26f7b84f455eeb2706f3d9fa8a4a3c5ee16","compiler_version":"v0.84.2","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","OPENAI_API_KEY"],"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":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"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.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"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.84.2). 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)
+# - OPENAI_API_KEY: (main workflow)
+#
+# Secrets used:
+# - COPILOT_GITHUB_TOKEN
+# - GH_AW_GITHUB_MCP_SERVER_TOKEN
+# - GH_AW_GITHUB_TOKEN
+# - GITHUB_TOKEN
+# - OPENAI_API_KEY
+#
+# 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+#
+# Container images used:
+# -
+# -
+# -
+# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00
+# - 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 }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+
+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_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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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.84.2"
+ 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 .antigravity .claude .codex .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ # poutine:ignore untrusted_checkout_exec
+ run: 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.84.2"
+ 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 }}
+ # poutine:ignore untrusted_checkout_exec
+ run: |
+ 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
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: 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_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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"gvisor\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE"
+
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 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_4ac93f4cf2185140_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":"true"},"report_incomplete":{}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_4ac93f4cf2185140_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.7'
+
+ 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"
+ 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*\"],\"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 COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \
+ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ AWF_REFLECT_ENABLED: 1
+ 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.84.2
+ 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 || invocations[0].outcome !== \"ok\") {\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:
+ 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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: |
+ mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
+ echo "Usage artifact source file status:"
+ for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
+ [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
+ done
+ [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true
+ [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
+ [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true
+ [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
+ [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
+ [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true
+ [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
+ [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
+ mkdir -p /tmp/gh-aw/usage/activity
+ node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs"
+ find /tmp/gh-aw/usage -type f -print | sort
+ - 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: "true"
+ 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();
+
+ 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_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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\":\"true\"},\"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
+ /tmp/gh-aw/process-safe-outputs.stdout.log
+ /tmp/gh-aw/process-safe-outputs.stderr.log
+ if-no-files-found: ignore
diff --git a/.github/workflows/smoke-bounded-agents-gvisor.md b/.github/workflows/smoke-bounded-agents-gvisor.md
new file mode 100644
index 000000000..1c3c0c9ae
--- /dev/null
+++ b/.github/workflows/smoke-bounded-agents-gvisor.md
@@ -0,0 +1,121 @@
+---
+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 }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+engine:
+ id: copilot
+ version: 1.0.34
+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"
+ printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \
+ "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf"
+ chmod +x "$HOME/.local/bin/awf"
+ node <<'NODE'
+ const fs = require("fs");
+ const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;
+ const config = JSON.parse(fs.readFileSync(file, "utf8"));
+ config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };
+ config.boundedAgents = {
+ enabled: true,
+ privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }],
+ runtime: "gvisor",
+ profile: "openai",
+ model: "gpt-4o-mini",
+ memoryLimit: "512m"
+ };
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
+ NODE
+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 || invocations[0].outcome !== "ok") {
+ 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
new file mode 100644
index 000000000..fc01bf40c
--- /dev/null
+++ b/.github/workflows/smoke-bounded-agents.lock.yml
@@ -0,0 +1,1369 @@
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"764391cb1121f45d194e98863b8369c07ed4e3cc392f5bc79d91728508d57a36","body_hash":"e86fc590352c926acbc317b5f565ea542ef92789a891987365678bf5365c6308","compiler_version":"v0.84.2","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","OPENAI_API_KEY"],"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":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"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.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"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.84.2). 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)
+# - OPENAI_API_KEY: (main workflow)
+#
+# Secrets used:
+# - COPILOT_GITHUB_TOKEN
+# - GH_AW_GITHUB_MCP_SERVER_TOKEN
+# - GH_AW_GITHUB_TOKEN
+# - GITHUB_TOKEN
+# - OPENAI_API_KEY
+#
+# 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+#
+# Container images used:
+# -
+# -
+# -
+# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00
+# - 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 }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+
+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_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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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.84.2"
+ 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 .antigravity .claude .codex .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ # poutine:ignore untrusted_checkout_exec
+ run: 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.84.2"
+ 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 }}
+ # poutine:ignore untrusted_checkout_exec
+ run: |
+ 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
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: 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_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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"docker\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE"
+
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 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_3112521b44aaa676_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":"true"},"report_incomplete":{}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_3112521b44aaa676_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.7'
+
+ 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: 20
+ 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"
+ 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*\"],\"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 COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \
+ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ AWF_REFLECT_ENABLED: 1
+ 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: 20
+ GH_AW_VERSION: v0.84.2
+ 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 }}
+ if: always()
+ name: Validate bounded-agent invocation
+ run: "node - \"$AUDIT_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, outputsPath] = process.argv.slice(2);\nconst records = fs.readFileSync(auditPath, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = records.filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1 || invocations[0].outcome !== \"ok\") {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.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:
+ 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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: |
+ mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
+ echo "Usage artifact source file status:"
+ for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
+ [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
+ done
+ [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true
+ [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
+ [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true
+ [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
+ [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
+ [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true
+ [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
+ [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
+ mkdir -p /tmp/gh-aw/usage/activity
+ node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs"
+ find /tmp/gh-aw/usage -type f -print | sort
+ - 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: "true"
+ 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: "20"
+ 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();
+
+ 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_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ 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@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2
+ 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\":\"true\"},\"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
+ /tmp/gh-aw/process-safe-outputs.stdout.log
+ /tmp/gh-aw/process-safe-outputs.stderr.log
+ if-no-files-found: ignore
diff --git a/.github/workflows/smoke-bounded-agents.md b/.github/workflows/smoke-bounded-agents.md
new file mode 100644
index 000000000..a6d701d89
--- /dev/null
+++ b/.github/workflows/smoke-bounded-agents.md
@@ -0,0 +1,104 @@
+---
+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 }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+engine:
+ id: copilot
+ version: 1.0.34
+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"
+ printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \
+ "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf"
+ chmod +x "$HOME/.local/bin/awf"
+ node <<'NODE'
+ const fs = require("fs");
+ const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;
+ const config = JSON.parse(fs.readFileSync(file, "utf8"));
+ config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };
+ config.boundedAgents = {
+ enabled: true,
+ privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }],
+ runtime: "docker",
+ profile: "openai",
+ model: "gpt-4o-mini",
+ memoryLimit: "512m"
+ };
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
+ NODE
+safe-outputs:
+ threat-detection:
+ enabled: false
+timeout-minutes: 20
+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
+ OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ run: |
+ node - "$AUDIT_LOG" "$OUTPUTS_FILE" <<'NODE'
+ const fs = require("fs");
+ const [auditPath, outputsPath] = process.argv.slice(2);
+ const records = fs.readFileSync(auditPath, "utf8").trim().split("\n")
+ .filter(Boolean).map((line) => JSON.parse(line));
+ const invocations = records.filter((record) =>
+ record.kind === "invocation" && record.sensitivity === "internal");
+ if (invocations.length !== 1 || invocations[0].outcome !== "ok") {
+ throw new Error(`expected one successful bounded-agent invocation, found ${invocations.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/test-bounded-agent-runtime-matrix.yml b/.github/workflows/test-bounded-agent-runtime-matrix.yml
new file mode 100644
index 000000000..2ff38534e
--- /dev/null
+++ b/.github/workflows/test-bounded-agent-runtime-matrix.yml
@@ -0,0 +1,93 @@
+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/README.md b/README.md
index 86029e798..d07006ab6 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su
- [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
- [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/bounded-agent-wrapper.sh b/containers/agent/bounded-agent-wrapper.sh
index 2be33975f..1c7373176 100644
--- a/containers/agent/bounded-agent-wrapper.sh
+++ b/containers/agent/bounded-agent-wrapper.sh
@@ -3,8 +3,9 @@
#
# Agent-facing bounded-agent CLI (protocol v1).
#
-# Forwards a *narrow* request to the trusted bounded-agent broker over the
-# Compose Unix socket. Like bounded-query-wrapper.sh, the API is deliberately
+# 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
@@ -27,6 +28,8 @@
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.
@@ -84,23 +87,51 @@ SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d
# The task must arrive on stdin; an interactive terminal means no task.
[ ! -t 0 ] || emit_error
-[ -n "$SOCKET" ] || emit_error
-[ -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
+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
diff --git a/containers/bounded-agent/Dockerfile b/containers/bounded-agent/Dockerfile
index 9ff44fd81..ddb5632c1 100644
--- a/containers/bounded-agent/Dockerfile
+++ b/containers/bounded-agent/Dockerfile
@@ -71,6 +71,11 @@ RUN chmod -R a-w /opt/awf \
&& 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 \
diff --git a/containers/bounded-agent/broker/broker.js b/containers/bounded-agent/broker/broker.js
index 872e7a218..eb6b48e36 100644
--- a/containers/bounded-agent/broker/broker.js
+++ b/containers/bounded-agent/broker/broker.js
@@ -60,11 +60,22 @@ function createBroker(params) {
// 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;
@@ -77,6 +88,7 @@ function createBroker(params) {
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;
}
@@ -87,6 +99,7 @@ function createBroker(params) {
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;
}
@@ -97,6 +110,7 @@ function createBroker(params) {
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;
}
@@ -168,6 +182,7 @@ function createBroker(params) {
// 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({
@@ -178,10 +193,12 @@ function createBroker(params) {
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);
}
}
@@ -224,6 +241,7 @@ function createBroker(params) {
// 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();
}
@@ -231,6 +249,7 @@ function createBroker(params) {
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(
diff --git a/containers/bounded-agent/broker/config.js b/containers/bounded-agent/broker/config.js
index 3d61d0c44..7d5731511 100644
--- a/containers/bounded-agent/broker/config.js
+++ b/containers/bounded-agent/broker/config.js
@@ -39,8 +39,10 @@ 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']);
+const SUPPORTED_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
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];
@@ -93,6 +95,30 @@ function parseDockerSize(name, fallback) {
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)) {
@@ -114,6 +140,22 @@ function loadConfig() {
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,
@@ -140,6 +182,13 @@ function loadConfig() {
// 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'),
@@ -174,9 +223,12 @@ function loadSeedMap(seedMapPath) {
module.exports = {
READY_PATH,
+ SBX_CAPABILITY_PATH,
MAX_TASK_BYTES,
SUPPORTED_BACKENDS,
SUPPORTED_PROFILES,
+ PRIMARY_BACKENDS,
loadConfig,
loadSeedMap,
+ loadSbxIngressCapabilities,
};
diff --git a/containers/bounded-agent/broker/enclave-runner.js b/containers/bounded-agent/broker/enclave-runner.js
index cda50984b..ba1cf31b7 100644
--- a/containers/bounded-agent/broker/enclave-runner.js
+++ b/containers/bounded-agent/broker/enclave-runner.js
@@ -2,6 +2,7 @@
const { DockerEnclaveRunner } = require('./docker-enclave-runner');
const { GvisorEnclaveRunner } = require('./gvisor-enclave-runner');
+const { SbxEnclaveRunner } = require('./sbx-enclave-runner');
const {
ENCLAVE_MAX_FILE_BYTES,
buildEnclaveArgs,
@@ -28,8 +29,11 @@ const {
*
* Unknown values fail closed. In particular, gVisor never falls back to the
* daemon's default OCI runtime when runsc is unavailable, and the `sbx`
- * backend has no launcher at all — it is rejected by host-side preflight long
- * before this code runs, and rejected again here.
+ * backend's `assertAvailable` always throws for the currently audited sbx CLI
+ * (see `./sbx-capability-probe.js`) — host-side preflight already blocks sbx
+ * long before this code runs, so reaching this branch at all would mean the
+ * defense-in-depth check inside the runner is the only thing standing between
+ * the request and an unproven enclave, and it fails closed too.
*
* @returns {EnclaveRunner}
*/
@@ -40,6 +44,9 @@ function createEnclaveRunner(config, deps = {}) {
if (config.backend === 'gvisor') {
return new GvisorEnclaveRunner(config, deps);
}
+ if (config.backend === 'sbx') {
+ return new SbxEnclaveRunner(config, deps);
+ }
throw new Error(`Unsupported bounded-agent backend: ${config.backend}`);
}
diff --git a/containers/bounded-agent/broker/runtime-telemetry.js b/containers/bounded-agent/broker/runtime-telemetry.js
new file mode 100644
index 000000000..a7e859285
--- /dev/null
+++ b/containers/bounded-agent/broker/runtime-telemetry.js
@@ -0,0 +1,64 @@
+'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/sbx-capability-probe.js b/containers/bounded-agent/broker/sbx-capability-probe.js
new file mode 100644
index 000000000..f1aaf1cd4
--- /dev/null
+++ b/containers/bounded-agent/broker/sbx-capability-probe.js
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+'use strict';
+
+const defaultSbxClient = require('./sbx-client');
+
+const AUDITED_SBX_VERSION = '0.37.1';
+const REQUIRED_CREATE_FLAGS = Object.freeze([
+ '--cpus',
+ '--memory',
+ '--name',
+ '--template',
+]);
+const REQUIRED_EXEC_FLAGS = Object.freeze([
+ '--user',
+ '--workdir',
+]);
+
+/**
+ * Capabilities that sbx must expose before AWF can safely launch a
+ * bounded-agent enclave VM.
+ *
+ * Unlike bounded queries (which run with `--network=none`), a bounded-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
+ * with exactly one reachable peer (the API proxy) and no route to any other
+ * member, including other enclave VMs on the same network. sbx v0.37.1 has no
+ * such primitive — its `--network` flag (if present at all) does not carry an
+ * enforced peer-isolation guarantee, so this control is always reported
+ * missing until AWF can name a specific, verifiable sbx flag or capability
+ * token that provides it. Local Docker/iptables rules are insufficient
+ * because organization sbx governance can replace them.
+ */
+const REQUIRED_HARD_ISOLATION_FLAGS = Object.freeze([
+ '--network',
+ '--pids-limit',
+ '--disk-limit',
+ '--ulimit-fsize',
+ '--mount-target',
+]);
+
+/** Capability primitive sbx does not yet expose under any flag name. */
+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';
+
+function includesFlag(help, flag) {
+ const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help);
+}
+
+async function inspectHelp(sbx, command) {
+ const result = await sbx.runSbx([command, '--help'], 10_000);
+ return result.exitCode === 0 ? result.stdout : '';
+}
+
+/**
+ * Probes the installed sbx CLI for the exact version, authentication, and
+ * hard-isolation flags a bounded-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
+ * yet verify (the pinned template/bootstrap and the lateral-peer-denial
+ * network primitive) are unconditionally appended to `missing`. A future sbx
+ * release that ships a concrete, checkable primitive for both must replace
+ * this unconditional block with a real check — it must never be removed
+ * without one.
+ */
+async function probeSbxCapabilities(sbx = defaultSbxClient) {
+ const versionResult = await sbx.runSbx(['version'], 10_000);
+ const daemonResult = await sbx.runSbx(['ls'], 10_000);
+ const createHelp = await inspectHelp(sbx, 'create');
+ const execHelp = await inspectHelp(sbx, 'exec');
+ const versionMatch = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout);
+ const version = versionMatch ? versionMatch[1] : undefined;
+ const missing = [];
+
+ missing.push(PINNED_TEMPLATE_MISSING);
+ missing.push(LATERAL_PEER_DENIAL_PRIMITIVE);
+
+ if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) {
+ missing.push('authenticated sbx CLI/daemon');
+ }
+ if (version && version !== AUDITED_SBX_VERSION) {
+ missing.push(`audited sbx version ${AUDITED_SBX_VERSION} (found ${version})`);
+ }
+ for (const flag of REQUIRED_CREATE_FLAGS) {
+ if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`);
+ }
+ for (const flag of REQUIRED_EXEC_FLAGS) {
+ if (!includesFlag(execHelp, flag)) missing.push(`sbx exec ${flag}`);
+ }
+ for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) {
+ if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`);
+ }
+
+ return Object.freeze({
+ supported: missing.length === 0,
+ version,
+ auditedVersion: AUDITED_SBX_VERSION,
+ missing: Object.freeze(missing),
+ });
+}
+
+async function main() {
+ const report = await probeSbxCapabilities();
+ process.stdout.write(`${JSON.stringify(report)}\n`);
+ process.exitCode = report.supported ? 0 : 1;
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ process.stdout.write(`${JSON.stringify({
+ supported: false,
+ auditedVersion: AUDITED_SBX_VERSION,
+ missing: ['capability probe failed'],
+ error: error.message,
+ })}\n`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ AUDITED_SBX_VERSION,
+ REQUIRED_CREATE_FLAGS,
+ REQUIRED_EXEC_FLAGS,
+ REQUIRED_HARD_ISOLATION_FLAGS,
+ LATERAL_PEER_DENIAL_PRIMITIVE,
+ PINNED_TEMPLATE_MISSING,
+ probeSbxCapabilities,
+};
diff --git a/containers/bounded-agent/broker/sbx-client.js b/containers/bounded-agent/broker/sbx-client.js
new file mode 100644
index 000000000..ab4327147
--- /dev/null
+++ b/containers/bounded-agent/broker/sbx-client.js
@@ -0,0 +1,44 @@
+'use strict';
+
+const { execFile } = require('child_process');
+
+const SBX_OUTPUT_LIMIT = 64 * 1024;
+const SBX_SAFE_PATH = '/usr/local/bin:/usr/bin:/bin';
+
+/**
+ * Executes an sbx management command with the broker's narrowly provisioned
+ * daemon credentials. The broker container never receives staging credentials,
+ * and this environment is not forwarded to enclave execution inside the VM.
+ *
+ * Proxy variables and XDG_CONFIG_HOME are removed for parity with the primary
+ * sbx management path: they can redirect daemon/credential lookup.
+ */
+function runSbx(args, timeoutMs) {
+ const env = { ...process.env };
+ delete env.DOCKER_SANDBOXES_PROXY;
+ delete env.XDG_CONFIG_HOME;
+ env.PATH = process.env.PATH || SBX_SAFE_PATH;
+
+ return new Promise((resolve) => {
+ execFile(
+ 'sbx',
+ args,
+ {
+ timeout: timeoutMs,
+ killSignal: 'SIGKILL',
+ maxBuffer: SBX_OUTPUT_LIMIT,
+ env,
+ },
+ (error, stdout, stderr) => {
+ resolve({
+ exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
+ timedOut: Boolean(error && error.killed),
+ stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '',
+ stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '',
+ });
+ },
+ );
+ });
+}
+
+module.exports = { runSbx };
diff --git a/containers/bounded-agent/broker/sbx-enclave-runner-spec.js b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js
new file mode 100644
index 000000000..d7cc9aad8
--- /dev/null
+++ b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const {
+ ENCLAVE_MAX_FILE_BYTES,
+ normalizeTimeoutMs,
+} = require('./enclave-runner-spec');
+const { REQUIRED_HARD_ISOLATION_FLAGS } = require('./sbx-capability-probe');
+
+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
+ * 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 TRUSTED_RUN_ID_PATTERN = /^[0-9a-f]{32}$/;
+const TRUSTED_INVOCATION_ID_PATTERN = /^[0-9a-f]{24}$/;
+const TRUSTED_SEED_ID_PATTERN = /^[0-9a-f]{16,64}$/;
+
+function assertTrustedId(name, value, pattern) {
+ if (typeof value !== 'string' || !pattern.test(value)) {
+ throw new Error(`${name} is not a broker-generated identifier`);
+ }
+}
+
+function freeze(values) {
+ return Object.freeze(values);
+}
+
+/**
+ * Derives the entire sbx CLI surface for one bounded-agent enclave invocation
+ * from trusted broker state.
+ *
+ * This specification is intentionally not launchable while the capability
+ * probe reports missing hard-isolation controls (see
+ * `SbxEnclaveRunner.assertAvailable`, which always throws for the currently
+ * audited sbx CLI). It records the exact sbx API a future, capability-proven
+ * sbx CLI would be driven with — a fixed uid/workdir, mandatory resource
+ * limits, and mount targets for the seed/task/schema/out channels — without
+ * ever accepting request-owned launch data.
+ */
+function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) {
+ assertTrustedId('runId', runId, TRUSTED_RUN_ID_PATTERN);
+ assertTrustedId('invocationId', invocationId, TRUSTED_INVOCATION_ID_PATTERN);
+ assertTrustedId('seedId', seedId, TRUSTED_SEED_ID_PATTERN);
+
+ const runPrefix = `awf-bounded-agent-sbx-${runId}-`;
+ const sandboxName = `${runPrefix}${invocationId}`;
+ const hostInvocationDir = `${config.sbxWorkDir}/${invocationId}`;
+ const hostSeedDir = `${config.sbxSeedsDir}/${seedId}`;
+ const workspaceDir = `${hostInvocationDir}/sbx-workspace`;
+ const taskPath = `${hostInvocationDir}/task.txt`;
+ const schemaPath = `${hostInvocationDir}/schema.json`;
+ const outPath = `${hostInvocationDir}/out`;
+
+ return Object.freeze({
+ sandboxName,
+ runPrefix,
+ createArgs: freeze([
+ 'create',
+ '--name', sandboxName,
+ '--cpus', String(config.cpuLimit),
+ '--memory', config.memoryLimit,
+ '--template', SBX_ENCLAVE_TEMPLATE,
+ // Distinct from bounded queries' `--network=none`: a bounded-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
+ // against a real launch while the capability probe reports it missing.
+ '--network', config.network,
+ '--pids-limit', String(config.pidsLimit),
+ '--disk-limit', config.tmpfsLimit,
+ '--ulimit-fsize', String(ENCLAVE_MAX_FILE_BYTES),
+ '--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`,
+ 'shell',
+ workspaceDir,
+ ]),
+ execArgs: freeze([
+ 'exec',
+ '--user', `${config.enclaveUid}:${config.enclaveGid}`,
+ '--workdir', config.enclaveMountDir,
+ sandboxName,
+ '/usr/local/bin/run-bounded-agent',
+ ]),
+ stopArgs: freeze(['stop', sandboxName]),
+ removeArgs: freeze(['rm', '--force', sandboxName]),
+ listArgs: freeze(['ls', '--json']),
+ });
+}
+
+module.exports = {
+ SBX_CLI_GRACE_MS,
+ SBX_ENCLAVE_TEMPLATE,
+ REQUIRED_HARD_ISOLATION_FLAGS,
+ deriveSbxEnclaveSpec,
+ normalizeTimeoutMs,
+};
diff --git a/containers/bounded-agent/broker/sbx-enclave-runner.js b/containers/bounded-agent/broker/sbx-enclave-runner.js
new file mode 100644
index 000000000..f3f91d7ec
--- /dev/null
+++ b/containers/bounded-agent/broker/sbx-enclave-runner.js
@@ -0,0 +1,152 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const defaultSbxClient = require('./sbx-client');
+const { probeSbxCapabilities } = require('./sbx-capability-probe');
+const {
+ SBX_CLI_GRACE_MS,
+ deriveSbxEnclaveSpec,
+ normalizeTimeoutMs,
+} = require('./sbx-enclave-runner-spec');
+
+function parseSandboxNames(stdout) {
+ let parsed;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch {
+ throw new Error('sbx returned malformed sandbox inventory');
+ }
+ if (!Array.isArray(parsed)) {
+ throw new Error('sbx returned malformed sandbox inventory');
+ }
+ const names = parsed.map((entry) => entry && entry.name);
+ if (names.some((name) => typeof name !== 'string' || !/^[a-z0-9][a-z0-9+.-]{0,127}$/.test(name))) {
+ throw new Error('sbx returned an invalid sandbox name');
+ }
+ return names;
+}
+
+/**
+ * EnclaveRunner backed by the sbx microVM CLI.
+ *
+ * Deliberately mirrors DockerEnclaveRunner's lifecycle contract exactly
+ * (assertAvailable / reconcileRun / cleanupInvocation / runEnclaveContainer)
+ * so the dispatcher in `./enclave-runner.js` can select it without any
+ * special-casing, and so its cleanup ordering (serialize, always run, throw
+ * last) is provably the same. `assertAvailable` always throws today: the
+ * audited sbx CLI cannot yet prove the mandatory isolation and
+ * API-proxy-only network controls (see `./sbx-capability-probe.js`), and
+ * this runner never falls back to Docker or gVisor.
+ */
+class SbxEnclaveRunner {
+ constructor(config, deps = {}) {
+ this.config = config;
+ this.sbx = deps.sbx || defaultSbxClient;
+ this.probe = deps.probe || probeSbxCapabilities;
+ this.files = deps.files || fs;
+ this.nowMs = deps.nowMs || Date.now;
+ this.cleanupTail = Promise.resolve();
+ }
+
+ spec(runId, invocationId, seedId) {
+ return deriveSbxEnclaveSpec({ config: this.config, runId, invocationId, seedId });
+ }
+
+ async assertAvailable() {
+ 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 ' +
+ `mandatory isolation controls (${report.missing.join(', ')}). No fallback is permitted.`,
+ );
+ }
+ }
+
+ serializeCleanup(operation) {
+ const queued = this.cleanupTail.then(operation, operation);
+ this.cleanupTail = queued.then(
+ () => undefined,
+ () => undefined,
+ );
+ return queued;
+ }
+
+ 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');
+ return parseSandboxNames(listed.stdout).filter((name) => name.startsWith(spec.runPrefix));
+ }
+
+ async removeSandbox(name) {
+ const stopped = await this.sbx.runSbx(['stop', name], 30_000);
+ 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');
+ }
+ }
+ 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');
+ }
+
+ /** Deterministic orphan cleanup for every VM name-prefixed with this run. */
+ async reconcileRun(runId) {
+ await this.serializeCleanup(async () => {
+ for (const name of await this.listRunSandboxes(runId)) {
+ await this.removeSandbox(name);
+ }
+ });
+ }
+
+ async cleanupInvocation(runId, invocationId) {
+ const { sandboxName } = this.spec(runId, invocationId, '0'.repeat(32));
+ await this.serializeCleanup(() => this.removeSandbox(sandboxName));
+ }
+
+ /**
+ * Runs one enclave to completion and always removes the VM before
+ * returning — including on timeout or a create/exec failure.
+ *
+ * stdout/stderr are intentionally dropped: the broker never reads, logs, or
+ * forwards enclave output, matching DockerEnclaveRunner.
+ */
+ async runEnclaveContainer(params) {
+ const spec = this.spec(params.runId, params.invocationId, params.seedId);
+ const totalTimeoutMs = normalizeTimeoutMs(
+ (params.timeoutMs ?? this.config.timeoutSeconds * 1000) + SBX_CLI_GRACE_MS,
+ );
+ const deadlineMs = this.nowMs() + totalTimeoutMs;
+ const remainingMs = () => normalizeTimeoutMs(deadlineMs - this.nowMs());
+ let result;
+ let runError;
+ try {
+ this.files.mkdirSync(path.join(this.config.sbxWorkDir, params.invocationId, 'sbx-workspace'), {
+ mode: 0o700,
+ });
+ const created = await this.sbx.runSbx(spec.createArgs, Math.min(120_000, remainingMs()));
+ if (created.timedOut) {
+ result = created;
+ } else if (created.exitCode !== 0) {
+ throw new Error('Failed to create bounded-agent sbx VM');
+ } else if (this.nowMs() >= deadlineMs) {
+ result = { exitCode: 124, timedOut: true, stdout: '', stderr: '' };
+ } else {
+ result = await this.sbx.runSbx(spec.execArgs, remainingMs());
+ }
+ } catch (error) {
+ runError = error;
+ }
+
+ try {
+ await this.cleanupInvocation(params.runId, params.invocationId);
+ } catch (cleanupError) {
+ throw cleanupError;
+ }
+ if (runError) throw runError;
+ return { exitCode: result.exitCode, timedOut: result.timedOut };
+ }
+}
+
+module.exports = { SbxEnclaveRunner, parseSandboxNames };
diff --git a/containers/bounded-agent/broker/server.js b/containers/bounded-agent/broker/server.js
index 766151ab6..b37f64421 100644
--- a/containers/bounded-agent/broker/server.js
+++ b/containers/bounded-agent/broker/server.js
@@ -1,6 +1,7 @@
'use strict';
const fs = require('fs');
+const crypto = require('crypto');
const http = require('http');
const { createAuditLog } = require('./audit');
const { createBroker } = require('./broker');
@@ -8,15 +9,20 @@ 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.
*
- * The broker itself has `network_mode: none`: it is not on `awf-net`, not on
- * `awf-ext`, and not on the dedicated bounded-agent enclave network. Its whole
- * agent-facing surface is one Unix domain socket shared through a tightly
- * scoped bind mount. It receives the Docker socket only because it launches
- * enclave containers.
+ * 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
@@ -27,7 +33,7 @@ const { createEnclaveRunner } = require('./enclave-runner');
* response on the agent-observable surface.
*
* `/query` always answers `200` with a canonical result body:
- * `{"status":"ok","result":}` or `{"status":"error"}` — status code and
+ * `{"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.
@@ -37,6 +43,9 @@ 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;
@@ -123,7 +132,15 @@ function createHardenedServer(listener, audit) {
return server;
}
-function processRequest(req, res, broker, audit, isAccepting = () => true) {
+function processRequest(
+ req,
+ res,
+ broker,
+ audit,
+ framedHeaders = req.headers,
+ framedRawHeaders = req.rawHeaders,
+ isAccepting = () => true,
+) {
if (req.socket.awfRejected) {
req.resume();
res.destroy();
@@ -147,7 +164,7 @@ function processRequest(req, res, broker, audit, isAccepting = () => true) {
return broker.handle(undefined, (result) => sendResult(res, result));
}
- const framed = buildRequestFromFrame(req.headers, req.rawHeaders, body.task);
+ 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));
@@ -164,11 +181,94 @@ function processRequest(req, res, broker, audit, isAccepting = () => true) {
function createServer(deps) {
const { broker, audit } = deps;
return createHardenedServer(
- (req, res, isAccepting) => processRequest(req, res, broker, audit, isAccepting),
+ (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 });
@@ -191,22 +291,47 @@ function listenOnSocket(server, config, audit) {
});
}
+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
- // containers left by a prior broker process for this exact run. Enclaves
+ // 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 });
- const server = createServer({ broker, audit });
+ const broker = createBroker({ config, seedMap: seeds, runId, audit, runner, telemetry });
+ const unixServer = createServer({ broker, audit });
+ const servers = [unixServer];
- await listenOnSocket(server, config, audit);
+ 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.
@@ -219,6 +344,7 @@ async function main() {
repos: seeds.size,
backend: config.backend,
profile: config.profile,
+ ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http',
maxInvocations: config.maxInvocations,
});
@@ -227,20 +353,39 @@ async function main() {
if (shuttingDown) return;
shuttingDown = true;
broker.close();
- server.freezeAdmissions();
- server.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([server.drainAdmissions(), broker.drain()]),
+ 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);
}
};
@@ -257,7 +402,9 @@ if (require.main === module) {
module.exports = {
createServer,
+ createTcpServer,
listenOnSocket,
+ listenOnTcp,
MAX_HEADER_BYTES,
MAX_CONNECTIONS,
};
diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md
index cccf8af89..ddaa7e537 100644
--- a/docs/awf-config-spec.md
+++ b/docs/awf-config-spec.md
@@ -2117,7 +2117,7 @@ The root object MAY contain a `boundedAgents` section:
|-------|------|---------|-------|
| `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` fails closed (§15.7). |
+| `runtime` | `docker` \| `gvisor` \| `sbx` | `docker` | `docker` and `gvisor` are implemented; `sbx` is capability-blocked (§15.7). |
| `profile` | `openai` \| `anthropic` | `openai` | Trusted provider protocol the enclave speaks to the API proxy. |
| `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. |
@@ -2143,8 +2143,16 @@ Bounded agents additionally REQUIRE, at preflight:
credential for `openai`, an Anthropic credential for `anthropic`);
- 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.
+ 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.
@@ -2256,7 +2264,13 @@ to derive a Compose project prefix at runtime).
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.
+ 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
@@ -2265,11 +2279,67 @@ 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 **fails closed** at preflight with an
-explicit not-yet-implemented capability error, and is rejected again by compose
-assembly and by the broker's runner factory. AWF has no audited single-use,
-API-proxy-only enclave launcher for it. Implementing it is deliberately out of
-scope for this release.
+`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
diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json
index 1cd457c06..9df2571fc 100644
--- a/docs/awf-config.schema.json
+++ b/docs/awf-config.schema.json
@@ -987,7 +987,7 @@
"gvisor",
"sbx"
],
- "description": "Sandbox runtime backend used to execute the enclave. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is accepted by this schema but fails closed at preflight with an explicit not-yet-implemented capability error. No backend ever falls back. Default: \"docker\".",
+ "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"
},
"profile": {
diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md
index 223403dc5..5a1cd1d29 100644
--- a/docs/bounded-agents.md
+++ b/docs/bounded-agents.md
@@ -56,12 +56,20 @@ Four trust stages, mirroring bounded queries:
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 Unix socket.** A dedicated `awf-bounded-agent-broker`
+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.
+ 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
@@ -110,7 +118,7 @@ Four trust stages, mirroring bounded queries:
|-------|---------|---------|
| `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 fails closed (see below). |
+| `runtime` | `docker` | `docker` or `gvisor`. `sbx` is accepted by the schema but remains capability-blocked (see below). |
| `profile` | `openai` | Provider protocol the enclave speaks to the API proxy: `openai` (`POST /v1/chat/completions`) or `anthropic` (`POST /v1/messages`). |
| `model` | — | Required when enabled. A request can never choose or override it. |
| `timeout` | `120` | Wall-clock seconds for one invocation (max 540). |
@@ -139,7 +147,13 @@ unless all of the following hold:
- `model` is set;
- a staging credential is present in `GH_TOKEN` or `GITHUB_TOKEN`;
- the Docker host is a Unix socket;
-- the selected runtime is actually available.
+- 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
@@ -158,14 +172,128 @@ run aborts instead.
Nothing else about the topology, mounts, budgets, or protocol changes between
the two backends.
-## `sbx` fails closed
+## `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:
-`runtime: "sbx"` is accepted by the JSON Schema so configurations can be written
-ahead of support landing, but it **fails closed** with an explicit
-not-yet-implemented capability error at preflight, and is rejected again by
-compose assembly and by the broker's runner factory. AWF has no audited
-single-use, API-proxy-only enclave launcher for `sbx`; support is deliberately
-deferred.
+```json
+{ "container": { "containerRuntime": "sbx" },
+ "boundedAgents": { "enabled": true, "runtime": "docker", "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
@@ -193,6 +321,13 @@ search) confined to the immutable seed, plus one terminal tool that records the
final answer. There is no shell, no `gh`, no git, no package manager, no host
state, no safe outputs, and no MCP.
+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
diff --git a/scripts/ci/probe-bounded-agent-primary-sbx.js b/scripts/ci/probe-bounded-agent-primary-sbx.js
new file mode 100755
index 000000000..f49095b1c
--- /dev/null
+++ b/scripts/ci/probe-bounded-agent-primary-sbx.js
@@ -0,0 +1,25 @@
+#!/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
new file mode 100644
index 000000000..eafa78263
--- /dev/null
+++ b/scripts/ci/report-bounded-agent-runtime-matrix.js
@@ -0,0 +1,158 @@
+#!/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
new file mode 100644
index 000000000..0f971592f
--- /dev/null
+++ b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts
@@ -0,0 +1,102 @@
+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/smoke-bounded-agent-enclave.sh b/scripts/ci/smoke-bounded-agent-enclave.sh
new file mode 100755
index 000000000..a5c3175d0
--- /dev/null
+++ b/scripts/ci/smoke-bounded-agent-enclave.sh
@@ -0,0 +1,144 @@
+#!/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")"
+
+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"
+chmod -R a+rX "$root/seed" "$root/task.txt" "$root/schema.json"
+chmod a+rw "$root/out"
+
+proxy_program='
+import json
+from http.server import BaseHTTPRequestHandler, HTTPServer
+class Handler(BaseHTTPRequestHandler):
+ def do_POST(self):
+ length = int(self.headers.get("content-length", "0"))
+ self.rfile.read(length)
+ payload = json.dumps({"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"live","type":"function","function":{"name":"finish","arguments":"{\"result\":true}"}}]}}]}).encode()
+ self.send_response(200)
+ self.send_header("content-type", "application/json")
+ self.send_header("content-length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+ 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 \
+ --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 \
+ -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" \
+ -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 HOME=/tmp -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
+ 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/src/awf-config-schema.json b/src/awf-config-schema.json
index 1cd457c06..9df2571fc 100644
--- a/src/awf-config-schema.json
+++ b/src/awf-config-schema.json
@@ -987,7 +987,7 @@
"gvisor",
"sbx"
],
- "description": "Sandbox runtime backend used to execute the enclave. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is accepted by this schema but fails closed at preflight with an explicit not-yet-implemented capability error. No backend ever falls back. Default: \"docker\".",
+ "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"
},
"profile": {
diff --git a/src/bounded-agent/broker.test.ts b/src/bounded-agent/broker.test.ts
index 45326ccef..cac655eea 100644
--- a/src/bounded-agent/broker.test.ts
+++ b/src/bounded-agent/broker.test.ts
@@ -9,6 +9,7 @@ const {
} = require(path.join(brokerDir, 'enclave-runner.js'));
const { DockerEnclaveRunner } = require(path.join(brokerDir, 'docker-enclave-runner.js'));
const { GvisorEnclaveRunner } = require(path.join(brokerDir, 'gvisor-enclave-runner.js'));
+const { SbxEnclaveRunner } = require(path.join(brokerDir, 'sbx-enclave-runner.js'));
const { createLedger } = require(path.join(brokerDir, 'ledger.js'));
const { CANONICAL_ERROR_JSON } = require(path.join(brokerDir, 'protocol.js'));
const { BOUNDED_AGENT_AUDIT_FILENAME } = require(path.join(brokerDir, 'audit.js'));
@@ -574,8 +575,12 @@ describe('bounded-agent enclave runner selection', () => {
expect(createEnclaveRunner({ ...config, backend: 'gvisor' })).toBeInstanceOf(GvisorEnclaveRunner);
});
- it('fails closed for any other backend, including sbx', () => {
- expect(() => createEnclaveRunner({ ...config, backend: 'sbx' })).toThrow(/Unsupported/);
+ 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/);
});
diff --git a/src/bounded-agent/ingress-conformance.test.ts b/src/bounded-agent/ingress-conformance.test.ts
new file mode 100644
index 000000000..bc303c6ba
--- /dev/null
+++ b/src/bounded-agent/ingress-conformance.test.ts
@@ -0,0 +1,254 @@
+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
new file mode 100644
index 000000000..7f2a35b19
--- /dev/null
+++ b/src/bounded-agent/ingress.test.ts
@@ -0,0 +1,104 @@
+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 { 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;
+
+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();
+ 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('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('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
new file mode 100644
index 000000000..a420764b0
--- /dev/null
+++ b/src/bounded-agent/ingress.ts
@@ -0,0 +1,119 @@
+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
index db9715363..646c6edd8 100644
--- a/src/bounded-agent/manager.test.ts
+++ b/src/bounded-agent/manager.test.ts
@@ -2,6 +2,7 @@ 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 { resolveBoundedAgentPaths } from './paths';
@@ -10,6 +11,7 @@ import {
boundedAgentManagerTestHelpers,
isBoundedAgentsEnabled,
prepareBoundedAgents,
+ reportBoundedAgentSbxIngressResult,
teardownBoundedAgents,
} from './manager';
import { releaseSeedPermissions, type GitRunner } from './staging';
@@ -167,6 +169,47 @@ describe('prepareBoundedAgents', () => {
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' },
@@ -234,6 +277,150 @@ describe('prepareBoundedAgents', () => {
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();
+ }
+ });
+ });
});
describe('teardownBoundedAgents', () => {
diff --git a/src/bounded-agent/manager.ts b/src/bounded-agent/manager.ts
index b94e07e3c..82a1aeb95 100644
--- a/src/bounded-agent/manager.ts
+++ b/src/bounded-agent/manager.ts
@@ -1,4 +1,5 @@
import * as fs from 'fs';
+import * as crypto from 'crypto';
import execa from 'execa';
import { logger } from '../logger';
import { getLocalDockerEnv } from '../host-env';
@@ -9,7 +10,11 @@ import {
resolveBoundedAgentPaths,
type BoundedAgentPaths,
} from './paths';
-import { assertEnclaveRuntimeAvailable, validateBoundedAgentConfig } from './preflight';
+import {
+ assertEnclaveRuntimeAvailable,
+ assertPrimaryRuntimeAvailable,
+ validateBoundedAgentConfig,
+} from './preflight';
import { writeBoundedAgentSkill } from './skill';
import { writeBoundedAgentWrapper } from './wrapper-artifact';
import { releaseSeedPermissions, resolveStagingToken, stageBoundedAgentSeeds, type GitRunner } from './staging';
@@ -20,6 +25,12 @@ import {
} 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';
/**
* Bounded-agent lifecycle orchestration.
@@ -144,18 +155,51 @@ export interface PrepareBoundedAgentsDeps {
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 enclave-runtime capability preflight (tests). */
assertRuntimeAvailable?: typeof assertEnclaveRuntimeAvailable;
+ /** Override primary-runtime capability preflight (tests). */
+ assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable;
+}
+
+interface SbxIngressCapabilities {
+ version: 1;
+ query: string;
+ probe: string;
+}
+
+function writeSbxIngressCapabilities(paths: BoundedAgentPaths): void {
+ const capabilities: SbxIngressCapabilities = {
+ version: 1,
+ query: crypto.randomBytes(32).toString('hex'),
+ probe: crypto.randomBytes(32).toString('hex'),
+ };
+ const fd = fs.openSync(
+ paths.capabilityPath,
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
+ 0o600,
+ );
+ try {
+ fs.writeSync(fd, JSON.stringify(capabilities));
+ fs.fchmodSync(fd, 0o600);
+ } finally {
+ fs.closeSync(fd);
+ }
}
/**
- * Validates configuration, proves the enclave runtime is available, stages one
- * immutable seed per configured repository, and writes the broker/agent
- * artifacts.
+ * 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.
+ * 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.
*/
@@ -172,8 +216,67 @@ export async function prepareBoundedAgents(
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;
- await assertRuntimeAvailable(boundedAgents);
+ 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()) ? 'unix' : 'sbx-http';
+ } catch (error) {
+ reportBoundedAgentSbxIngressResult(config, 'failed');
+ throw error;
+ }
+ }
const paths = resolveBoundedAgentPaths(config.workDir);
assertBoundedAgentPrivateRootIsolated(config, paths, env);
@@ -198,6 +301,9 @@ export async function prepareBoundedAgents(
}
prepareDirectories(paths);
+ if (config.boundedAgentIngressTransport === 'sbx-http') {
+ writeSbxIngressCapabilities(paths);
+ }
const runId = generateBoundedAgentRunId();
const staging = await stageBoundedAgentSeeds({
@@ -233,6 +339,48 @@ export async function prepareBoundedAgents(
);
}
+/**
+ * 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 {
@@ -319,4 +467,5 @@ export const boundedAgentManagerTestHelpers = {
readRunId,
removeOrphanEnclaveContainers,
removePrivateState,
+ writeSbxIngressCapabilities,
};
diff --git a/src/bounded-agent/paths.ts b/src/bounded-agent/paths.ts
index a248ed3ec..34fc0afaa 100644
--- a/src/bounded-agent/paths.ts
+++ b/src/bounded-agent/paths.ts
@@ -52,6 +52,8 @@ export interface BoundedAgentPaths {
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. */
@@ -66,6 +68,9 @@ 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
@@ -152,6 +157,7 @@ export function resolveBoundedAgentPaths(
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),
};
}
diff --git a/src/bounded-agent/preflight.test.ts b/src/bounded-agent/preflight.test.ts
index 217084a28..58b2610ba 100644
--- a/src/bounded-agent/preflight.test.ts
+++ b/src/bounded-agent/preflight.test.ts
@@ -1,10 +1,12 @@
import {
assertEnclaveRuntimeAvailable,
+ assertPrimaryRuntimeAvailable,
resolveApiProxyRoute,
validateBoundedAgentConfig,
} from './preflight';
import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options';
import type { WrapperConfig } from '../types';
+import * as boundedQueryPreflight from '../bounded-query/preflight';
/**
* Fail-closed preflight coverage.
@@ -111,26 +113,36 @@ describe('validateBoundedAgentConfig', () => {
).toMatch(/bare owner\/repo slug/);
});
- it('fails closed on the not-yet-implemented sbx backend with an explicit capability error', () => {
- const errors = validateBoundedAgentConfig(
- config({ boundedAgents: boundedAgents({ runtime: 'sbx' }) }),
- env,
- );
- expect(errors.join('\n')).toMatch(/"sbx" is not yet implemented/);
- expect(errors.join('\n')).toMatch(/never downgrade/);
+ 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 the implemented docker and gvisor backends', () => {
- for (const runtime of ['docker', 'gvisor'] as const) {
+ 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('fails closed before staging for a primary microVM agent runtime', () => {
- const errors = validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env);
- expect(errors.join('\n')).toMatch(/require a Docker Compose primary agent/);
- expect(errors.join('\n')).toMatch(/fails closed before staging/);
+ 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', () => {
@@ -218,9 +230,118 @@ describe('assertEnclaveRuntimeAvailable', () => {
).rejects.toThrow(/never fall back to a weaker runtime/);
});
- it('has no launcher for sbx', async () => {
+ 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: 'sbx' }), async () => true, async () => true),
+ 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'],
+ ] 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
index b44d1457b..71116de01 100644
--- a/src/bounded-agent/preflight.ts
+++ b/src/bounded-agent/preflight.ts
@@ -9,28 +9,42 @@ import {
MAX_TASK_BYTES,
} from './protocol';
import { resolveStagingToken } from '../bounded-query/staging';
-import { runtimeUsesComposeAgent } from '../container-runtime';
+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, and combinations of AWF
- * settings under which a bounded agent cannot be exposed securely.
+ * 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 rather than
- * silently downgrading — in particular, an unavailable `runsc` never falls
- * back to the default Docker runtime, and the not-yet-implemented `sbx`
- * backend never falls back to Docker or gVisor.
+ * 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']);
-/** Enclave runtimes the schema accepts but preflight blocks. */
-const BLOCKED_ENCLAVE_RUNTIMES = new Set(['sbx']);
+/** Every enclave runtime the schema accepts, implemented or capability-gated. */
+const SUPPORTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']);
/** Docker OCI runtime name required for the `gvisor` enclave runtime. */
const GVISOR_DOCKER_RUNTIME = 'runsc';
@@ -41,6 +55,9 @@ export type DockerRuntimeQuery = (runtimeName: string) => Promise;
/** Detects whether the Docker daemon required by the enclave backend is reachable. */
export type DockerAvailabilityQuery = () => Promise;
+/** Detects whether the sbx primary-agent runtime is installed and authenticated. */
+export type SbxAvailabilityQuery = () => Promise;
+
const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => {
const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], {
env: getLocalDockerEnv(),
@@ -65,6 +82,38 @@ const defaultDockerAvailabilityQuery: DockerAvailabilityQuery = async () => {
return result.exitCode === 0;
};
+/**
+ * Executes the minimum host-side primary-agent capability proof for sbx: an
+ * authenticated, non-mutating `sbx ls`. This is deliberately narrower than
+ * {@link defaultBoundedAgentSbxCapabilityQuery}, which proves the *enclave*
+ * axis; the primary axis only needs to know the CLI/daemon is reachable.
+ */
+const defaultSbxAvailabilityQuery: SbxAvailabilityQuery = async () => {
+ try {
+ const managementEnv = { ...process.env };
+ delete managementEnv.DOCKER_SANDBOXES_PROXY;
+ delete managementEnv.XDG_CONFIG_HOME;
+ const result = await execa('sbx', ['ls'], {
+ reject: false,
+ timeout: 10_000,
+ env: managementEnv,
+ });
+ return result.exitCode === 0;
+ } catch {
+ return false;
+ }
+};
+
+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.
*
@@ -111,13 +160,10 @@ export function validateBoundedAgentConfig(
const errors: string[] = [];
- if (!runtimeUsesComposeAgent(config.containerRuntime)) {
- errors.push(
- `bounded agents require a Docker Compose primary agent, but container runtime ` +
- `"${config.containerRuntime}" uses an external microVM agent. No audited bounded-agent ` +
- 'ingress exists for that execution model, so AWF fails closed before staging.',
- );
- }
+ // 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(
@@ -148,15 +194,11 @@ export function validateBoundedAgentConfig(
seenKeys.add(key);
}
- if (BLOCKED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) {
+ if (!SUPPORTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) {
errors.push(
- `boundedAgents.runtime "${boundedAgents.runtime}" is not yet implemented. AWF has no audited ` +
- 'single-use, API-proxy-only enclave launcher for it, and bounded agents never downgrade to a ' +
- 'weaker runtime. Use "docker" or "gvisor".',
- );
- } else if (!IMPLEMENTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) {
- errors.push(
- `boundedAgents.runtime "${boundedAgents.runtime}" is not supported. Use "docker" or "gvisor".`,
+ `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".',
);
}
@@ -227,8 +269,13 @@ export function validateBoundedAgentConfig(
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 (dockerHost && !dockerHost.startsWith('unix://')) {
+ 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.',
@@ -249,13 +296,17 @@ export function validateBoundedAgentConfig(
* Verifies that the requested enclave runtime is actually available.
*
* Only reached after {@link validateBoundedAgentConfig} accepted the runtime
- * name, so the only remaining question is daemon support. gVisor requires an
- * exact `runsc` registration and is never downgraded.
+ * 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))) {
@@ -278,19 +329,100 @@ export async function assertEnclaveRuntimeAvailable(
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,
- BLOCKED_ENCLAVE_RUNTIMES,
+ SUPPORTED_ENCLAVE_RUNTIMES,
GVISOR_DOCKER_RUNTIME,
defaultDockerRuntimeQuery,
defaultDockerAvailabilityQuery,
+ defaultSbxAvailabilityQuery,
isDockerSize,
};
diff --git a/src/bounded-agent/runtime-matrix.test.ts b/src/bounded-agent/runtime-matrix.test.ts
new file mode 100644
index 000000000..de565febe
--- /dev/null
+++ b/src/bounded-agent/runtime-matrix.test.ts
@@ -0,0 +1,330 @@
+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 };
+ },
+ readEnclaveOutput: (outPath: string) => {
+ const output = outputs.get(outPath);
+ return output !== undefined && Buffer.byteLength(output) <= 8192 ? output : undefined;
+ },
+ 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
new file mode 100644
index 000000000..59db0b879
--- /dev/null
+++ b/src/bounded-agent/runtime-matrix.ts
@@ -0,0 +1,116 @@
+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
new file mode 100644
index 000000000..26d51f041
--- /dev/null
+++ b/src/bounded-agent/sbx-capability.test.ts
@@ -0,0 +1,156 @@
+/* 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
new file mode 100644
index 000000000..780fab629
--- /dev/null
+++ b/src/bounded-agent/sbx-capability.ts
@@ -0,0 +1,134 @@
+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
new file mode 100644
index 000000000..50bc5f6c9
--- /dev/null
+++ b/src/bounded-agent/sbx-enclave-runner.test.ts
@@ -0,0 +1,457 @@
+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
new file mode 100644
index 000000000..cfa20338f
--- /dev/null
+++ b/src/bounded-agent/sbx-runner.test.ts
@@ -0,0 +1,169 @@
+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/workspace-artifacts.test.ts b/src/bounded-agent/workspace-artifacts.test.ts
index 01cca5d3b..25e3afa89 100644
--- a/src/bounded-agent/workspace-artifacts.test.ts
+++ b/src/bounded-agent/workspace-artifacts.test.ts
@@ -1,6 +1,7 @@
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';
@@ -213,13 +214,57 @@ describe('bounded-agent CLI wrapper source', () => {
expect(wrapper).not.toMatch(/exit\s+[1-9]/);
});
- it('never forwards a proxy, credential, endpoint, or runtime control', () => {
+ 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', 'X-AWF-Capability']) {
+ 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/commands/main-action.test.ts b/src/commands/main-action.test.ts
index 3d5b09e6a..09fd37f81 100644
--- a/src/commands/main-action.test.ts
+++ b/src/commands/main-action.test.ts
@@ -19,6 +19,7 @@ jest.mock('./signal-handler');
jest.mock('./validate-options');
jest.mock('../sbx-manager');
jest.mock('../bounded-query/ingress');
+jest.mock('../bounded-agent/ingress');
import { logger } from '../logger';
import * as dockerManager from '../docker-manager';
@@ -33,6 +34,7 @@ import * as signalHandler from './signal-handler';
import * as validateOptions from './validate-options';
import * as sbxManager from '../sbx-manager';
import * as boundedQueryIngress from '../bounded-query/ingress';
+import * as boundedAgentIngress from '../bounded-agent/ingress';
import { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils';
const {
@@ -56,6 +58,7 @@ const mockedSignalHandler = signalHandler as jest.Mocked;
const mockedValidateOptions = validateOptions as jest.Mocked;
const mockedSbxManager = sbxManager as jest.Mocked;
const mockedBoundedQueryIngress = boundedQueryIngress as jest.Mocked;
+const mockedBoundedAgentIngress = boundedAgentIngress as jest.Mocked;
describe('createMainAction', () => {
let processExitSpy: jest.SpyInstance;
@@ -395,9 +398,153 @@ describe('createMainAction', () => {
expect(JSON.stringify(logCalls)).not.toContain(capability);
expect(mockedBoundedQueryIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig);
});
+
+ it('mounts only bounded-agent agent artifacts and injects the HTTP capability without logging it', async () => {
+ const capability = 'c'.repeat(64);
+ const sbxConfig = {
+ ...MAIN_ACTION_STUB_CONFIG,
+ containerRuntime: 'sbx',
+ containerWorkDir: '/workspace',
+ enableApiProxy: true,
+ boundedAgentIngressTransport: 'sbx-http',
+ boundedAgents: {
+ enabled: true,
+ privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
+ runtime: 'docker',
+ profile: 'openai',
+ model: 'gpt-4o-mini',
+ timeout: 120,
+ memoryLimit: '512m',
+ tmpfsLimit: '64m',
+ cpuLimit: '1',
+ pidsLimit: 128,
+ maxInvocations: 8,
+ maxModelRequests: 8,
+ maxModelTokens: 1024,
+ maxOutputBytes: 8192,
+ maxTaskBytes: 4096,
+ },
+ } as unknown as import('../types').WrapperConfig;
+ mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig);
+ mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({
+ endpoint: 'http://host.docker.internal:49153/query',
+ queryCapability: capability,
+ probeCapability: 'd'.repeat(64),
+ skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md',
+ wrapperDir: '/var/tmp/bounded-agent-ingress/skill',
+ });
+ mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => {
+ await deps.startContainers('/tmp/awf-test', ['github.com']);
+ return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode;
+ });
+
+ const action = createMainAction(getOptionValueSource);
+ await action(['bounded-agent --repo octo/private'], {});
+
+ const createOptions = mockedSbxManager.createSandbox.mock.calls[0][0];
+ const mounts = createOptions.extraMounts ?? [];
+ expect(mounts).toHaveLength(1);
+ expect(mounts[0]).toMatch(/awf-bounded-agent-ingress-.*\/skill:ro$/);
+ expect(mounts.join(' ')).not.toMatch(/seeds|work|control|audit|docker\.sock|seed-map/);
+
+ expect(mockedSbxManager.assertSbxBoundedAgentIngress).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ transport: 'sbx-http',
+ endpoint: 'http://host.docker.internal:49153/query',
+ probeCapability: 'd'.repeat(64),
+ }),
+ expect.any(Object),
+ '/workspace',
+ );
+ const execCalls = mockedSbxManager.execInSandbox.mock.calls;
+ const agentEnvironment = execCalls[execCalls.length - 1]?.[2]?.environment;
+ expect(agentEnvironment).toEqual(expect.objectContaining({
+ AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:49153/query',
+ AWF_BOUNDED_AGENT_CAPABILITY: capability,
+ AWF_BOUNDED_AGENT_BIN_DIR: expect.stringMatching(/\/skill$/),
+ }));
+ const logCalls = [
+ ...mockedLogger.debug.mock.calls,
+ ...mockedLogger.info.mock.calls,
+ ...mockedLogger.warn.mock.calls,
+ ...mockedLogger.error.mock.calls,
+ ];
+ expect(JSON.stringify(logCalls)).not.toContain(capability);
+ expect(mockedBoundedAgentIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig);
+
+ // Thread 4: telemetry must never report `ready` before the sbx ingress
+ // proof (assertSbxBoundedAgentIngress) actually succeeds.
+ const telemetryEvents = mockedLogger.info.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)));
+ expect(telemetryEvents).toContainEqual(expect.objectContaining({
+ primaryBackend: 'sbx',
+ capabilityState: 'supported',
+ category: 'ready',
+ }));
+ });
+
+ it('reports a terminal unproven event and never `ready` when sbx ingress proof fails', async () => {
+ const sbxConfig = {
+ ...MAIN_ACTION_STUB_CONFIG,
+ containerRuntime: 'sbx',
+ containerWorkDir: '/workspace',
+ enableApiProxy: true,
+ boundedAgentIngressTransport: 'sbx-http',
+ boundedAgents: {
+ enabled: true,
+ privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }],
+ runtime: 'docker',
+ profile: 'openai',
+ model: 'gpt-4o-mini',
+ timeout: 120,
+ memoryLimit: '512m',
+ tmpfsLimit: '64m',
+ cpuLimit: '1',
+ pidsLimit: 128,
+ maxInvocations: 8,
+ maxModelRequests: 8,
+ maxModelTokens: 1024,
+ maxOutputBytes: 8192,
+ maxTaskBytes: 4096,
+ },
+ } as unknown as import('../types').WrapperConfig;
+ mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig);
+ mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({
+ endpoint: 'http://host.docker.internal:49154/query',
+ queryCapability: 'e'.repeat(64),
+ probeCapability: 'f'.repeat(64),
+ skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md',
+ wrapperDir: '/var/tmp/bounded-agent-ingress/skill',
+ });
+ mockedSbxManager.assertSbxBoundedAgentIngress.mockRejectedValueOnce(
+ new Error('sbx host does not support the selected bounded-agent sbx-http ingress'),
+ );
+ mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => {
+ await deps.startContainers('/tmp/awf-test', ['github.com']);
+ return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode;
+ });
+
+ const action = createMainAction(getOptionValueSource);
+ await expect(action(['bounded-agent --repo octo/private'], {})).rejects.toThrow('process.exit: 1');
+
+ const telemetryEvents = mockedLogger.info.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)));
+ expect(telemetryEvents.some((event) => event.category === 'ready')).toBe(false);
+ expect(telemetryEvents).toContainEqual(expect.objectContaining({
+ primaryBackend: 'sbx',
+ capabilityState: 'unavailable',
+ category: 'primary-sbx-ingress-unproven',
+ }));
+ });
});
});
+
describe('when runMainWorkflow throws', () => {
it('calls performCleanup and exits with code 1', async () => {
mockedCliWorkflow.runMainWorkflow.mockRejectedValue(new Error('docker failed'));
diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts
index ec4cc32af..6734a68ce 100644
--- a/src/commands/main-action.ts
+++ b/src/commands/main-action.ts
@@ -30,6 +30,7 @@ import { runtimeUsesComposeAgent } from '../container-runtime';
import {
assertSbxApiProxyReflect,
assertSbxBoundedQueryIngress,
+ assertSbxBoundedAgentIngress,
createSandbox,
execInSandbox,
removeSandbox,
@@ -37,7 +38,11 @@ import {
SBX_DEFAULT_NAME,
} from '../sbx-manager';
import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager';
-import { prepareBoundedAgents, teardownBoundedAgents } from '../bounded-agent/manager';
+import {
+ prepareBoundedAgents,
+ reportBoundedAgentSbxIngressResult,
+ teardownBoundedAgents,
+} from '../bounded-agent/manager';
import type { WrapperConfig } from '../types';
import { buildAgentEnvironment } from '../services/agent-service';
import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env';
@@ -48,6 +53,11 @@ import {
resolveSbxIngress,
} from '../bounded-query/ingress';
import { resolveBoundedQueryPaths } from '../bounded-query/paths';
+import {
+ removeSbxIngressCapabilityFile as removeBoundedAgentSbxIngressCapabilityFile,
+ resolveSbxIngress as resolveBoundedAgentSbxIngress,
+} from '../bounded-agent/ingress';
+import { resolveBoundedAgentPaths } from '../bounded-agent/paths';
/** Report whether a secret is set (and its length) without exposing the value. */
function redactSecret(value: string | undefined): string {
@@ -300,6 +310,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
const SBX_GATEWAY_IP = '172.17.0.0';
const SBX_HOST_DOCKER_INTERNAL = 'host.docker.internal';
const boundedQueryPaths = resolveBoundedQueryPaths(config.workDir);
+ const boundedAgentPaths = resolveBoundedAgentPaths(config.workDir);
const sbxMounts = [...(config.volumeMounts ?? [])];
let sbxBoundedQueryIngress:
| { transport: 'unix'; socketPath: string }
@@ -310,6 +321,15 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
probeCapability: string;
}
| undefined;
+ let sbxBoundedAgentIngress:
+ | { transport: 'unix'; socketPath: string }
+ | {
+ transport: 'sbx-http';
+ endpoint: string;
+ queryCapability: string;
+ probeCapability: string;
+ }
+ | undefined;
if (config.boundedQueries?.enabled) {
sbxMounts.push(`${boundedQueryPaths.agentDir}:ro`);
@@ -330,6 +350,25 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
}
}
+ if (config.boundedAgents?.enabled) {
+ sbxMounts.push(`${boundedAgentPaths.agentDir}:ro`);
+ if (config.boundedAgentIngressTransport === 'unix') {
+ sbxMounts.push(`${boundedAgentPaths.runDir}:ro`);
+ sbxBoundedAgentIngress = {
+ transport: 'unix',
+ socketPath: boundedAgentPaths.socketPath,
+ };
+ } else {
+ const ingress = await resolveBoundedAgentSbxIngress(config);
+ sbxBoundedAgentIngress = {
+ transport: 'sbx-http',
+ endpoint: ingress.endpoint,
+ queryCapability: ingress.queryCapability,
+ probeCapability: ingress.probeCapability,
+ };
+ }
+ }
+
sbxEnvironment = buildAgentEnvironment({
config,
networkConfig: {
@@ -408,6 +447,50 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
}
}
+ if (sbxBoundedAgentIngress) {
+ try {
+ await assertSbxBoundedAgentIngress(
+ sbxName,
+ sbxBoundedAgentIngress.transport === 'unix'
+ ? sbxBoundedAgentIngress
+ : {
+ transport: 'sbx-http',
+ endpoint: sbxBoundedAgentIngress.endpoint,
+ probeCapability: sbxBoundedAgentIngress.probeCapability,
+ },
+ sbxEnvironment,
+ config.containerWorkDir,
+ );
+ } catch (error) {
+ // Preflight only proved the sbx CLI and enclave capability exist;
+ // this is the executable proof that the selected ingress
+ // transport is actually reachable from inside the sandbox. Never
+ // report `ready` telemetry when that proof fails.
+ reportBoundedAgentSbxIngressResult(config, 'failed');
+ throw error;
+ }
+ // Ingress is proven reachable now — this is the only point a
+ // primary-sbx run is ever reported `ready`.
+ reportBoundedAgentSbxIngressResult(config, 'proven');
+
+ Object.assign(sbxEnvironment, {
+ AWF_BOUNDED_AGENT_SKILL: boundedAgentPaths.skillPath,
+ AWF_BOUNDED_AGENT_REPOS: config.boundedAgents!.privateRepos
+ .map((repository) => repository.repo)
+ .join(','),
+ AWF_BOUNDED_AGENT_BIN_DIR: boundedAgentPaths.agentDir,
+ ...(sbxBoundedAgentIngress.transport === 'unix'
+ ? { AWF_BOUNDED_AGENT_SOCKET: sbxBoundedAgentIngress.socketPath }
+ : {
+ AWF_BOUNDED_AGENT_ENDPOINT: sbxBoundedAgentIngress.endpoint,
+ AWF_BOUNDED_AGENT_CAPABILITY: sbxBoundedAgentIngress.queryCapability,
+ }),
+ });
+ if (sbxBoundedAgentIngress.transport === 'sbx-http') {
+ removeBoundedAgentSbxIngressCapabilityFile(config);
+ }
+ }
+
// gh-aw fetches reflection data from the fixed api-proxy hostname. The
// microVM reaches the sidecar through its published host ports, so install
// that alias and prove the real endpoint before launching the agent.
diff --git a/src/compose-generator.ts b/src/compose-generator.ts
index d864c82ca..8d4041b35 100644
--- a/src/compose-generator.ts
+++ b/src/compose-generator.ts
@@ -14,6 +14,7 @@ import { runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runt
import { API_PROXY_PORTS } from './types/ports';
import { EXTERNAL_BRIDGE_NAME } from './config/network-policy';
import { BOUNDED_QUERY_INGRESS_NETWORK } from './bounded-query/ingress';
+import { BOUNDED_AGENT_INGRESS_NETWORK } from './bounded-agent/ingress';
import {
BOUNDED_AGENT_EGRESS_NETWORK,
BOUNDED_AGENT_NETWORK,
@@ -216,6 +217,19 @@ export function generateDockerCompose(
name: BOUNDED_AGENT_EGRESS_NETWORK,
driver: 'bridge',
};
+ if (
+ config.boundedAgentIngressTransport === 'sbx-http'
+ || (config.boundedAgentIngressTransport === undefined && !includeAgent)
+ ) {
+ // Distinct from BOUNDED_AGENT_NETWORK (the enclave/API-proxy network):
+ // this is a dedicated `internal` bridge whose only members are the
+ // broker and, transiently, the primary sbx microVM's host-gateway
+ // route — never an enclave, never the primary agent's own network.
+ compose.networks[BOUNDED_AGENT_INGRESS_NETWORK] = {
+ driver: 'bridge',
+ internal: true,
+ };
+ }
}
return compose;
}
diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts
index 9e2a33051..5ec49b6d6 100644
--- a/src/sbx-manager.test.ts
+++ b/src/sbx-manager.test.ts
@@ -1,5 +1,6 @@
import {
assertSbxApiProxyReflect,
+ assertSbxBoundedAgentIngress,
assertSbxBoundedQueryIngress,
createSandbox,
execInSandbox,
@@ -212,6 +213,90 @@ describe('sbx-manager', () => {
}));
});
+ it('proves sbx-http ingress with a capability-authenticated HTTP exchange', async () => {
+ mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
+
+ await expect(assertSbxBoundedQueryIngress(
+ 'awf-agent-test',
+ { transport: 'sbx-http', endpoint: 'http://host.docker.internal:49152/query', probeCapability: 'p'.repeat(64) },
+ {},
+ '/workspace',
+ )).resolves.toBeUndefined();
+
+ const args: string[] = mockExecaFn.mock.calls[0][1];
+ const command = args[args.length - 1];
+ expect(command).toContain('$AWF_BOUNDED_QUERY_ENDPOINT');
+ expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_QUERY_PROBE_CAPABILITY');
+ expect(command).not.toContain('p'.repeat(64));
+ });
+
+ it('fails closed when the probe exchange does not return the canonical error body', async () => {
+ mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' });
+
+ await expect(assertSbxBoundedQueryIngress(
+ 'awf-agent-test',
+ { transport: 'unix', socketPath: '/var/tmp/broker.sock' },
+ {},
+ )).rejects.toThrow(/sbx host does not support the selected bounded-query unix ingress/);
+ });
+
+ describe('assertSbxBoundedAgentIngress', () => {
+ it('proves Unix ingress with an HTTP exchange over the mounted socket, using its own env vars', async () => {
+ mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
+
+ await expect(assertSbxBoundedAgentIngress(
+ 'awf-agent-test',
+ { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' },
+ {},
+ '/workspace',
+ )).resolves.toBeUndefined();
+
+ const args: string[] = mockExecaFn.mock.calls[0][1];
+ const command = args[args.length - 1];
+ expect(command).toContain('--unix-socket "$AWF_BOUNDED_AGENT_SOCKET"');
+ expect(command).toContain('http://localhost/query');
+ expect(command).toContain('{"status":"error"}');
+ // Must never reuse the bounded-query env var names.
+ expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET');
+ });
+
+ it('proves sbx-http ingress with a capability-authenticated HTTP exchange, distinct from bounded queries', async () => {
+ mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
+ const environment: Record = {};
+
+ await expect(assertSbxBoundedAgentIngress(
+ 'awf-agent-test',
+ {
+ transport: 'sbx-http',
+ endpoint: 'http://host.docker.internal:49153/query',
+ probeCapability: 'q'.repeat(64),
+ },
+ environment,
+ '/workspace',
+ )).resolves.toBeUndefined();
+
+ const args: string[] = mockExecaFn.mock.calls[0][1];
+ const command = args[args.length - 1];
+ expect(command).toContain('$AWF_BOUNDED_AGENT_ENDPOINT');
+ expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY');
+ expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET');
+ expect(command).not.toContain('AWF_BOUNDED_QUERY_ENDPOINT');
+ // The capability value itself is passed only via the execInSandbox
+ // environment map, never inlined into the shell command string.
+ expect(command).not.toContain('q'.repeat(64));
+ });
+
+ it('fails closed when the bounded-agent probe exchange fails, with a distinct error message', async () => {
+ mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' });
+
+ await expect(assertSbxBoundedAgentIngress(
+ 'awf-agent-test',
+ { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' },
+ {},
+ )).rejects.toThrow(/sbx host does not support the selected bounded-agent unix ingress/);
+ });
+ });
+
describe('assertSbxApiProxyReflect', () => {
it('installs a resolver alias and probes the reflection endpoint with Node fetch', async () => {
mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
@@ -702,7 +787,7 @@ describe('sbx-manager', () => {
expect(args).toContain('-lc');
const shellCommand = args[args.length - 1];
expect(shellCommand).toBe(
- 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version',
+ 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version',
);
expect(shellCommand.indexOf('.local/bin')).toBeLessThan(
shellCommand.indexOf('copilot --version'),
@@ -722,7 +807,7 @@ describe('sbx-manager', () => {
describe('withLocalBinOnPath', () => {
it('prepends ~/.local/bin using the runtime $HOME', () => {
expect(withLocalBinOnPath('copilot')).toBe(
- 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot',
+ 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot',
);
});
diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts
index dff0b2ef1..0e0866bde 100644
--- a/src/sbx-manager.ts
+++ b/src/sbx-manager.ts
@@ -366,9 +366,15 @@ export async function createSandbox(config: {
* installed rootless to ~/.local/bin (install_copilot_cli.sh --rootless) stays
* resolvable by name. `$HOME` resolves to the injected HOME (getRealUserHome),
* which matches the wholesale-mounted home tool dirs.
+ *
+ * Also prepends the bounded-query and/or bounded-agent wrapper directories
+ * (`AWF_BOUNDED_QUERY_BIN_DIR` / `AWF_BOUNDED_AGENT_BIN_DIR`) when those
+ * subsystems are enabled, since a primary sbx microVM has no `/tmp/awf-lib`
+ * chroot-relative PATH entry to fall back on. Either variable is empty (and
+ * therefore a no-op) unless its subsystem is enabled for this run.
*/
function withLocalBinOnPath(command: string): string {
- return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`;
+ return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}\${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`;
}
/** @internal Exposed for unit tests only. */
@@ -581,6 +587,57 @@ export async function assertSbxBoundedQueryIngress(
}
}
+/**
+ * Proves the selected bounded-agent broker ingress is reachable from the
+ * actual primary sandbox before the agent command starts. Mirrors
+ * {@link assertSbxBoundedQueryIngress} exactly (same `/query` route and
+ * canonical `{"status":"error"}` body), against the bounded-agent broker's
+ * own env var names and capability. The HTTP probe uses a separate one-shot
+ * capability distinct from the query capability that is injected only after
+ * this proof succeeds.
+ */
+export async function assertSbxBoundedAgentIngress(
+ name: string,
+ ingress:
+ | { transport: 'unix'; socketPath: string }
+ | { transport: 'sbx-http'; endpoint: string; probeCapability: string },
+ environment: Record,
+ workDir?: string,
+): Promise {
+ const probeEnvironment = { ...environment };
+ let command: string;
+ if (ingress.transport === 'unix') {
+ probeEnvironment.AWF_BOUNDED_AGENT_SOCKET = ingress.socketPath;
+ command = [
+ 'response=$(curl --silent --show-error --max-time 15 --unix-socket "$AWF_BOUNDED_AGENT_SOCKET"',
+ '-X POST -H "Expect:"',
+ 'http://localhost/query 2>/dev/null) &&',
+ '[ "$response" = \'{"status":"error"}\' ]',
+ ].join(' ');
+ } else {
+ probeEnvironment.AWF_BOUNDED_AGENT_ENDPOINT = ingress.endpoint;
+ probeEnvironment.AWF_BOUNDED_AGENT_PROBE_CAPABILITY = ingress.probeCapability;
+ command = [
+ 'response=$(curl --silent --show-error --noproxy "*" --max-time 15',
+ '-X POST -H "Expect:"',
+ '-H "X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY"',
+ '"$AWF_BOUNDED_AGENT_ENDPOINT" 2>/dev/null) &&',
+ '[ "$response" = \'{"status":"error"}\' ]',
+ ].join(' ');
+ }
+
+ const result = await execInSandbox(name, command, {
+ timeoutMinutes: 1,
+ workDir,
+ environment: probeEnvironment,
+ });
+ if (result.exitCode !== 0) {
+ throw new Error(
+ `sbx host does not support the selected bounded-agent ${ingress.transport} ingress`,
+ );
+ }
+}
+
/**
* Adds a resolver alias for the published API proxy and proves that the
* hard-coded gh-aw reflection endpoint is reachable before the agent starts.
diff --git a/src/services/bounded-agent-service.test.ts b/src/services/bounded-agent-service.test.ts
index 8ecd9ea68..b66d3e5e8 100644
--- a/src/services/bounded-agent-service.test.ts
+++ b/src/services/bounded-agent-service.test.ts
@@ -122,6 +122,40 @@ describe('bounded-agent broker in generated Docker Compose', () => {
}
});
+ it('gives the dedicated proxy only the selected provider credential and no external telemetry authority', () => {
+ const result = generateDockerCompose(
+ {
+ ...enabled(),
+ anthropicApiKey: 'sk-ant-unused',
+ copilotGithubToken: 'gh-unused',
+ geminiApiKey: 'gemini-unused',
+ additionalEnv: {
+ ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid',
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret',
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'https://telemetry.invalid',
+ OTEL_EXPORTER_OTLP_HEADERS: 'authorization=secret',
+ },
+ },
+ networkConfig,
+ );
+ const proxy = result.services['bounded-agent-api-proxy'] as unknown as Record;
+ const env = proxy.environment as Record;
+ expect(env.OPENAI_API_KEY).toBe('sk-real');
+ for (const forbidden of [
+ 'ANTHROPIC_API_KEY',
+ 'COPILOT_GITHUB_TOKEN',
+ 'GEMINI_API_KEY',
+ 'ACTIONS_ID_TOKEN_REQUEST_URL',
+ 'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
+ 'OTEL_EXPORTER_OTLP_ENDPOINT',
+ 'OTEL_EXPORTER_OTLP_HEADERS',
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ ]) {
+ expect(env).not.toHaveProperty(forbidden);
+ }
+ });
+
it('keeps squid, the primary agent, and the broker off the enclave network', () => {
const result = generateDockerCompose(enabled(), networkConfig);
for (const name of ['squid-proxy', 'agent', 'bounded-agent-broker']) {
@@ -257,7 +291,7 @@ describe('buildBoundedAgentService guards', () => {
).toThrow(/must be enabled/);
});
- it('fails closed for the not-yet-implemented sbx backend', () => {
+ it('fails closed for boundedAgents.runtime "sbx" because current sbx cannot prove mandatory isolation controls', () => {
expect(() =>
buildBoundedAgentService({
config: {
@@ -268,7 +302,7 @@ describe('buildBoundedAgentService guards', () => {
imageConfig,
networkConfig,
}),
- ).toThrow(/sbx bounded-agent backend is not implemented/);
+ ).toThrow(/boundedAgents\.runtime "sbx" is capability-blocked/);
});
it('refuses to wire an enclave with no API proxy to talk to', () => {
diff --git a/src/services/bounded-agent-service.ts b/src/services/bounded-agent-service.ts
index 109d2dbf1..1d6383b5b 100644
--- a/src/services/bounded-agent-service.ts
+++ b/src/services/bounded-agent-service.ts
@@ -31,6 +31,13 @@ import { applyHostPathPrefixToVolumes } from './host-path-prefix';
import { buildContainerSecurityHardening } from './service-security';
import type { ImageBuildConfig, NetworkConfig } from './squid-service';
import { buildApiProxyServiceConfig } from './api-proxy-service-config';
+import { resolveDockerHostGateway } from './host-gateway';
+import {
+ BOUNDED_AGENT_INGRESS_NETWORK,
+ BOUNDED_AGENT_TCP_PORT,
+} from '../bounded-agent/ingress';
+import { resolveBoundedAgentPrimaryBackend } from '../bounded-agent/runtime-matrix';
+import { runtimeUsesComposeAgent } from '../container-runtime';
import {
ANTHROPIC_ENV,
COPILOT_ENV,
@@ -165,8 +172,10 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
}
if (boundedAgents.runtime === 'sbx') {
throw new Error(
- 'buildBoundedAgentService: the sbx bounded-agent backend is not implemented; bounded agents ' +
- 'fail closed rather than downgrading to a Docker or gVisor enclave',
+ 'buildBoundedAgentService: boundedAgents.runtime "sbx" is capability-blocked — the installed sbx ' +
+ 'runtime cannot yet prove all mandatory enclave-isolation controls (see assertEnclaveRuntimeAvailable ' +
+ 'and BoundedAgentSbxCapabilityReport.missing), so no enclave broker wiring is generated and there is ' +
+ 'no Docker-socket or credential fallback',
);
}
if (!config.enableApiProxy) {
@@ -180,6 +189,12 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
const { enclaveImageRef, enclaveSource, brokerSource } = resolveBoundedAgentImages(imageConfig);
const dockerSocketPath = resolveDockerSocketPath(config);
const apiPort = resolveBoundedAgentApiPort(boundedAgents.profile);
+ const ingressTransport = config.boundedAgentIngressTransport
+ ?? (runtimeUsesComposeAgent(config.containerRuntime) ? 'unix' : 'sbx-http');
+ const sbxIngressHostIp = ingressTransport === 'sbx-http' ? resolveDockerHostGateway() : undefined;
+ if (ingressTransport === 'sbx-http' && !sbxIngressHostIp) {
+ throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress');
+ }
const apiProxyService = buildApiProxyServiceConfig({
config,
@@ -229,9 +244,19 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
const service: Record = {
container_name: BOUNDED_AGENT_BROKER_CONTAINER_NAME,
...brokerSource,
- // The broker is deliberately networkless: it never joins the enclave
- // network it launches enclaves onto.
- network_mode: 'none',
+ // The broker is deliberately networkless when the primary agent shares a
+ // Unix-socket-mountable host with it: it never joins the enclave network
+ // it launches enclaves onto. When the primary agent is a microVM that
+ // cannot receive that bind mount (sbx-http transport), the broker instead
+ // joins a *separate*, dedicated `internal` ingress bridge — distinct from
+ // BOUNDED_AGENT_NETWORK — so it still never shares a network with an
+ // enclave, the primary agent's own network, Squid, or the API proxy.
+ ...(ingressTransport === 'unix'
+ ? { network_mode: 'none' }
+ : {
+ networks: [BOUNDED_AGENT_INGRESS_NETWORK],
+ ports: [`${sbxIngressHostIp}::${BOUNDED_AGENT_TCP_PORT}`],
+ }),
volumes: applyHostPathPrefixToVolumes(
[
`${paths.seedsDir}:${BROKER_SEEDS_DIR}:ro`,
@@ -249,6 +274,7 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
// The broker selects a fixed EnclaveRunner from this normalized value.
// Runtime flags are never accepted from an invocation.
AWF_BOUNDED_AGENT_BACKEND: boundedAgents.runtime,
+ AWF_BOUNDED_AGENT_PRIMARY_BACKEND: resolveBoundedAgentPrimaryBackend(config.containerRuntime),
AWF_BOUNDED_AGENT_NETWORK: BOUNDED_AGENT_NETWORK,
AWF_BOUNDED_AGENT_API_ENDPOINT: `http://${BOUNDED_AGENT_API_PROXY_IP}:${apiPort}`,
AWF_BOUNDED_AGENT_PROFILE: boundedAgents.profile,
@@ -269,6 +295,9 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
AWF_BOUNDED_AGENT_HOST_SEEDS_DIR: toDaemonVisiblePath(paths.seedsDir, config.dockerHostPathPrefix),
AWF_BOUNDED_AGENT_SOCKET_UID: getSafeHostUid(),
AWF_BOUNDED_AGENT_SOCKET_GID: getSafeHostGid(),
+ ...(ingressTransport === 'sbx-http'
+ ? { AWF_BOUNDED_AGENT_TCP_PORT: String(BOUNDED_AGENT_TCP_PORT) }
+ : {}),
},
depends_on: {
'bounded-agent-image': {
@@ -295,7 +324,7 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
};
const agentEnvAdditions: Record = {
- AWF_BOUNDED_AGENT_SOCKET: AGENT_SOCKET_PATH,
+ ...(ingressTransport === 'unix' ? { AWF_BOUNDED_AGENT_SOCKET: AGENT_SOCKET_PATH } : {}),
AWF_BOUNDED_AGENT_SKILL: AGENT_SKILL_PATH,
AWF_BOUNDED_AGENT_REPOS: boundedAgents.privateRepos.map((repository) => repository.repo).join(','),
};
@@ -314,7 +343,8 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou
logger.info(
`Bounded agents enabled - enclave runtime: ${boundedAgents.runtime}, ` +
- `profile: ${boundedAgents.profile}, enclave network: ${BOUNDED_AGENT_NETWORK} (API proxy only)`,
+ `profile: ${boundedAgents.profile}, enclave network: ${BOUNDED_AGENT_NETWORK} (API proxy only), ` +
+ `broker ingress transport: ${ingressTransport}`,
);
return { enclaveImageService, service, apiProxyService, agentEnvAdditions, agentVolumes };
diff --git a/src/types/bounded-agent-options.ts b/src/types/bounded-agent-options.ts
index dfb00090a..5a5045d0c 100644
--- a/src/types/bounded-agent-options.ts
+++ b/src/types/bounded-agent-options.ts
@@ -100,8 +100,10 @@ export interface BoundedAgentsConfig {
* Sandbox runtime backend used to execute the enclave.
*
* `docker` and `gvisor` are implemented. `sbx` is accepted by the schema
- * but fails closed at preflight with an explicit not-yet-implemented
- * capability error — no backend ever downgrades.
+ * but is capability-gated: preflight probes the installed sbx CLI and
+ * blocks before any repository is staged unless every mandatory
+ * isolation and API-proxy-only network primitive can be proven. No
+ * backend ever downgrades.
*
* @default 'docker'
*/
@@ -228,6 +230,18 @@ export const BOUNDED_AGENT_DEFAULTS: Readonly<
maxModelTokens: 1024,
};
+/**
+ * Transport used between the primary agent and the bounded-agent broker.
+ *
+ * Compose agents (docker, gvisor) always use `unix`: the broker's socket is
+ * bind-mounted directly into the agent container. A primary sbx microVM
+ * cannot receive that bind mount, so it uses `unix` only when an executable
+ * passthrough probe proves the microVM can reach a host-mounted Unix socket;
+ * otherwise it falls back to `sbx-http`, an authenticated loopback-only HTTP
+ * transport on a dedicated internal network (see `./ingress.ts`).
+ */
+export type BoundedAgentIngressTransport = 'unix' | 'sbx-http';
+
export interface BoundedAgentOptions {
/**
* Normalized bounded-agent enclave configuration.
@@ -239,4 +253,15 @@ export interface BoundedAgentOptions {
* @default undefined
*/
boundedAgents?: BoundedAgentsConfig;
+
+ /**
+ * Trusted runtime state selected by bounded-agent preflight.
+ *
+ * This is not a user-configurable field and is never accepted from the AWF
+ * config file. Compose agents always use `unix`; sbx uses `unix` only when
+ * an executable passthrough probe succeeds, otherwise `sbx-http`.
+ *
+ * @internal
+ */
+ boundedAgentIngressTransport?: BoundedAgentIngressTransport;
}