From d5cf3e4eae3f441b39368a1872babd584757396a Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 28 Jun 2026 10:07:03 -0700 Subject: [PATCH 1/5] fix: return 429 instead of 403 when max turns exceeded The API proxy was returning HTTP 403 when the max-runs limit was hit, which caused the Copilot CLI harness to misinterpret it as an authentication failure rather than a turn budget exhaustion. Change the status code to 429 (Too Many Requests) which more accurately reflects the condition and allows clients to distinguish auth failures from resource limits. Also bumps the contribution-check workflow max-turns from 3 to 4 to give the agent enough headroom to complete its task. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/contribution-check.lock.yml | 61 ++++++------------- .github/workflows/contribution-check.md | 2 +- .../api-proxy/guards/common-guard-checks.js | 6 +- .../api-proxy/server.token-guards.test.js | 5 +- containers/api-proxy/server.websocket.test.js | 4 +- 5 files changed, 27 insertions(+), 51 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index f46a1f2b1..42f96fe42 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d962e0361f4f9a10ae1b65a8f797e21813baaf1d58927d1725111e6b4024227b","body_hash":"52cb85230b6e6e94e8e7422142eb939315bd320477f4b5fd934c712bb9721dfe","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"39a0cef32ef50be5a0fe9680e5931b0c3c81cd73a9dc24cd6e9fe66f9fc7218d","body_hash":"52cb85230b6e6e94e8e7422142eb939315bd320477f4b5fd934c712bb9721dfe","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"}]} # This file was automatically generated by gh-aw (v0.81.6). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -211,6 +211,16 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + sparse-checkout: | + .github + .agents + .antigravity + .claude + .codex + .crush + .gemini + .opencode + .pi sparse-checkout-cone-mode: true fetch-depth: 1 - name: Save agent config folders for base branch restoration @@ -472,36 +482,8 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.65 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 < "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/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\",\"codeload.github.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"patch-diff.githubusercontent.com\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":4,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"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*\"],\"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-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-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*\"],\"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*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"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\"],\"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-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${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="" @@ -779,7 +761,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2086 - sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --session-state-dir /tmp/gh-aw/sandbox/agent/session-state --enable-host-access --allow-host-ports 80,443,8080 --build-local \ + sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ -- /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 && 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 @@ -789,7 +771,7 @@ jobs: COPILOT_MODEL: gpt-5.4-mini GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} - GH_AW_MAX_TURNS: 3 + GH_AW_MAX_TURNS: 4 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 }} @@ -823,16 +805,7 @@ jobs: - 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 + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true diff --git a/.github/workflows/contribution-check.md b/.github/workflows/contribution-check.md index a675137c7..437abddfb 100644 --- a/.github/workflows/contribution-check.md +++ b/.github/workflows/contribution-check.md @@ -11,7 +11,7 @@ permissions: contents: read pull-requests: read issues: read -max-turns: 3 +max-turns: 4 concurrency: group: "contribution-check-${{ github.event.pull_request.number || github.ref }}" cancel-in-progress: true diff --git a/containers/api-proxy/guards/common-guard-checks.js b/containers/api-proxy/guards/common-guard-checks.js index b989ad6b1..1a2a774a6 100644 --- a/containers/api-proxy/guards/common-guard-checks.js +++ b/containers/api-proxy/guards/common-guard-checks.js @@ -80,8 +80,10 @@ function buildCommonGuardChecks(deps, model) { { block: getMaxRunsBlockState(), isBlocked: block => block && block.maxExceeded, - // Terminal hard cap — non-retryable (see effective-tokens guard above). - statusCode: 403, + // Terminal hard cap: 429 signals the agent has exhausted its allowed + // turns. Unlike transient rate-limits, the Retry-After is absent so + // well-behaved clients won't retry indefinitely. + statusCode: 429, eventName: 'max_runs_exceeded', buildError: buildMaxRunsExceededError, buildLogFields: block => ({ diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index cc0209f2f..6f8142713 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -231,7 +231,7 @@ describe('proxyRequest max-runs guard', () => { jest.restoreAllMocks(); }); - it('returns 403 with structured payload when max runs limit is exceeded', async () => { + it('returns 429 with structured payload when max runs limit is exceeded', async () => { const cycle = createMockUpstreamCycle(https); // First request completes successfully — consumes the single allowed run @@ -251,11 +251,12 @@ describe('proxyRequest max-runs guard', () => { await flushPromises(); expect(cycle.spy).toHaveBeenCalledTimes(1); - expect(res2.writeHead).toHaveBeenCalledWith(403, expect.objectContaining({ + expect(res2.writeHead).toHaveBeenCalledWith(429, expect.objectContaining({ 'Content-Type': 'application/json', })); const payload = JSON.parse(res2.end.mock.calls[0][0]); expect(payload.error.type).toBe('max_runs_exceeded'); + expect(payload.error.message).toMatch(/Maximum LLM invocations exceeded/); expect(payload.error.max_runs).toBe(1); expect(payload.error.invocation_count).toBe(1); }); diff --git a/containers/api-proxy/server.websocket.test.js b/containers/api-proxy/server.websocket.test.js index 7dc86b242..ad3ef1165 100644 --- a/containers/api-proxy/server.websocket.test.js +++ b/containers/api-proxy/server.websocket.test.js @@ -323,14 +323,14 @@ describe('proxyWebSocket security guards', () => { jest.restoreAllMocks(); }); - it('blocks with 403 when max-runs limit is exceeded', () => { + it('blocks with 429 when max-runs limit is exceeded', () => { process.env.AWF_MAX_RUNS = '1'; applyMaxRunsInvocation(); // consume the single allowed run const socket = makeMockSocket(); wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 403 Forbidden')); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 429 Too Many Requests')); expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('"max_runs_exceeded"')); expect(socket.destroy).toHaveBeenCalled(); }); From a7324c2215bbacf58a9ecf961c1b737efef9651e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 28 Jun 2026 10:11:58 -0700 Subject: [PATCH 2/5] Fix contribution-check test to expect max-turns: 4 Update test assertion to match the new max-turns value in the contribution-check workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/contribution-check-workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/contribution-check-workflow.test.ts b/scripts/ci/contribution-check-workflow.test.ts index 7f61c24db..b729db34a 100644 --- a/scripts/ci/contribution-check-workflow.test.ts +++ b/scripts/ci/contribution-check-workflow.test.ts @@ -34,7 +34,7 @@ describe('contribution-check workflow', () => { it('has conservative turn limit and appropriate model', () => { const source = fs.readFileSync(sourcePath, 'utf-8'); - expect(source).toContain('max-turns: 3'); + expect(source).toContain('max-turns: 4'); expect(source).toContain('model: gpt-5.4-mini'); }); }); From c4cbe11b560d673a1b455dec2e920dfd6760ac42 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 28 Jun 2026 11:55:08 -0700 Subject: [PATCH 3/5] fix: handle EACCES during chroot-home cleanup in rootless Docker In rootless Docker mode, files created inside the agent container are owned by remapped UIDs that the host process cannot delete. This caused cleanup to fail with EACCES when removing the chroot-home directory. Apply the same rootless permission repair pattern already used for artifact directories: on EACCES, spin up a short-lived container with CHOWN/DAC_OVERRIDE/FOWNER capabilities to fix ownership, then retry removal. Fixes github/gh-aw#42101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/artifact-preservation.ts | 34 ++++++++++++++++++++++++++++++++-- src/container-cleanup.ts | 7 ++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index 005631242..f031a5214 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -220,11 +220,41 @@ export function preserveCleanupArtifacts( ); } -export function removeWorkDirectories(workDir: string): void { +type RemoveWorkDirectoriesOptions = { + dockerHostPathPrefix?: string; + imageRegistry?: string; + imageTag?: string; + agentImage?: string; +}; + +export function removeWorkDirectories(workDir: string, options: RemoveWorkDirectoriesOptions = {}): void { fs.rmSync(workDir, { recursive: true, force: true }); const chrootHomeDir = `${workDir}-chroot-home`; if (fs.existsSync(chrootHomeDir)) { - fs.rmSync(chrootHomeDir, { recursive: true, force: true }); + try { + fs.rmSync(chrootHomeDir, { recursive: true, force: true }); + } catch (error: unknown) { + // In rootless Docker, files created inside the container may be owned by + // remapped UIDs that the host process cannot delete. Fix permissions via + // a privileged container, then retry removal. + if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { + logger.debug('Chroot home removal failed with EACCES; attempting rootless permission repair'); + fixArtifactPermissionsForRootless( + [chrootHomeDir], + options.dockerHostPathPrefix, + options.imageRegistry, + options.imageTag, + options.agentImage, + ); + try { + fs.rmSync(chrootHomeDir, { recursive: true, force: true }); + } catch (retryError) { + logger.warn('Failed to remove chroot home directory after permission repair:', retryError); + } + } else { + logger.warn('Failed to remove chroot home directory:', error); + } + } } } diff --git a/src/container-cleanup.ts b/src/container-cleanup.ts index 35453d249..ed6c6883d 100644 --- a/src/container-cleanup.ts +++ b/src/container-cleanup.ts @@ -50,7 +50,12 @@ export async function cleanup( await unmountSslTmpfs(sslDir); } - removeWorkDirectories(workDir); + removeWorkDirectories(workDir, { + dockerHostPathPrefix, + imageRegistry, + imageTag, + agentImage, + }); logger.debug('Temporary files cleaned up'); } catch (error) { logger.warn('Failed to clean up temporary files:', error); From 760641fb7a75af724fbe14ef36ae638be6dc4200 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:10:44 +0000 Subject: [PATCH 4/5] test: cover chroot-home cleanup EACCES retry --- src/artifact-preservation-errors.test.ts | 60 ++++++++++++++++++++++++ src/artifact-preservation.ts | 10 ++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/artifact-preservation-errors.test.ts b/src/artifact-preservation-errors.test.ts index 194144183..2ab756fdb 100644 --- a/src/artifact-preservation-errors.test.ts +++ b/src/artifact-preservation-errors.test.ts @@ -15,6 +15,15 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory()); +jest.mock('./artifact-permissions', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const real = jest.requireActual('./artifact-permissions'); + return { + ...real, + fixArtifactPermissionsForRootless: jest.fn(real.fixArtifactPermissionsForRootless), + }; +}); + // Wrap the fs methods we need to control with jest.fn() so individual tests // can inject one-shot errors without using jest.spyOn (which fails on non- // configurable properties in Jest's module sandbox). @@ -24,6 +33,7 @@ jest.mock('fs', () => { return { ...real, copyFileSync: jest.fn(real.copyFileSync), + rmSync: jest.fn(real.rmSync), renameSync: jest.fn(real.renameSync), mkdirSync: jest.fn(real.mkdirSync), }; @@ -32,16 +42,21 @@ jest.mock('fs', () => { import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { fixArtifactPermissionsForRootless } from './artifact-permissions'; import { mockExecaSync } from './test-helpers/mock-execa.test-utils'; import { preserveIptablesAudit, preserveCleanupArtifacts, + removeWorkDirectories, } from './artifact-preservation'; // Cast mocked methods for convenient mockImplementationOnce usage. // eslint-disable-next-line @typescript-eslint/no-require-imports const realFs = jest.requireActual('fs'); const mockCopyFileSync = fs.copyFileSync as jest.MockedFunction; +const mockFixArtifactPermissionsForRootless = + fixArtifactPermissionsForRootless as jest.MockedFunction; +const mockRmSync = fs.rmSync as jest.MockedFunction; const mockRenameSync = fs.renameSync as jest.MockedFunction; const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; @@ -56,6 +71,7 @@ describe('artifact-preservation – error paths', () => { mockExecaSync.mockReturnValue({ stdout: '', stderr: '', exitCode: 0 }); // Re-apply real implementations in case a test replaced them with mockImplementation/mockImplementationOnce. mockCopyFileSync.mockImplementation(realFs.copyFileSync); + mockRmSync.mockImplementation(realFs.rmSync); mockRenameSync.mockImplementation(realFs.renameSync); mockMkdirSync.mockImplementation(realFs.mkdirSync); }); @@ -317,4 +333,48 @@ describe('artifact-preservation – error paths', () => { } }); }); + + describe('removeWorkDirectories', () => { + it('repairs chroot-home permissions and retries removal after EACCES', () => { + const workDir = makeTempDir(); + const chrootHomeDir = `${workDir}-chroot-home`; + let chrootHomeRemovalAttempts = 0; + + try { + realFs.mkdirSync(chrootHomeDir); + realFs.writeFileSync(path.join(chrootHomeDir, '.aws-config'), 'config'); + + mockRmSync.mockImplementation((target, options) => { + if (target === chrootHomeDir) { + chrootHomeRemovalAttempts += 1; + if (chrootHomeRemovalAttempts === 1) { + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + throw error; + } + } + return realFs.rmSync(target, options); + }); + + expect(() => removeWorkDirectories(workDir, { + dockerHostPathPrefix: '/host', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + agentImage: 'act', + })).not.toThrow(); + + expect(mockFixArtifactPermissionsForRootless).toHaveBeenCalledWith( + [chrootHomeDir], + '/host', + 'ghcr.io/github/gh-aw-firewall', + 'latest', + 'act', + ); + expect(chrootHomeRemovalAttempts).toBe(2); + expect(realFs.existsSync(chrootHomeDir)).toBe(false); + } finally { + realFs.rmSync(workDir, { recursive: true, force: true }); + realFs.rmSync(chrootHomeDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index f031a5214..00c638e8b 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -220,12 +220,10 @@ export function preserveCleanupArtifacts( ); } -type RemoveWorkDirectoriesOptions = { - dockerHostPathPrefix?: string; - imageRegistry?: string; - imageTag?: string; - agentImage?: string; -}; +type RemoveWorkDirectoriesOptions = Pick< + PreserveCleanupArtifactsOptions, + 'dockerHostPathPrefix' | 'imageRegistry' | 'imageTag' | 'agentImage' +>; export function removeWorkDirectories(workDir: string, options: RemoveWorkDirectoriesOptions = {}): void { fs.rmSync(workDir, { recursive: true, force: true }); From 6d8ce9d60aef8a05cb884cc513c3bf419ea717a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:26:11 +0000 Subject: [PATCH 5/5] revert: drop unrelated workflow and 429 guard changes --- .github/workflows/contribution-check.lock.yml | 61 +++++++++++++------ .github/workflows/contribution-check.md | 2 +- .../api-proxy/guards/common-guard-checks.js | 6 +- .../api-proxy/server.token-guards.test.js | 5 +- containers/api-proxy/server.websocket.test.js | 4 +- .../ci/contribution-check-workflow.test.ts | 2 +- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 42f96fe42..f46a1f2b1 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"39a0cef32ef50be5a0fe9680e5931b0c3c81cd73a9dc24cd6e9fe66f9fc7218d","body_hash":"52cb85230b6e6e94e8e7422142eb939315bd320477f4b5fd934c712bb9721dfe","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d962e0361f4f9a10ae1b65a8f797e21813baaf1d58927d1725111e6b4024227b","body_hash":"52cb85230b6e6e94e8e7422142eb939315bd320477f4b5fd934c712bb9721dfe","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"}]} # This file was automatically generated by gh-aw (v0.81.6). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -211,16 +211,6 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - sparse-checkout: | - .github - .agents - .antigravity - .claude - .codex - .crush - .gemini - .opencode - .pi sparse-checkout-cone-mode: true fetch-depth: 1 - name: Save agent config folders for base branch restoration @@ -482,8 +472,36 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.65 env: GH_HOST: github.com - - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.11 + - 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 < "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/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\",\"codeload.github.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"patch-diff.githubusercontent.com\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":3,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"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*\"],\"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-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-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*\"],\"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*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"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\"],\"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-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${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="" @@ -761,7 +779,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2086 - sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --session-state-dir /tmp/gh-aw/sandbox/agent/session-state --enable-host-access --allow-host-ports 80,443,8080 --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 && 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 @@ -771,7 +789,7 @@ jobs: COPILOT_MODEL: gpt-5.4-mini GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} - GH_AW_MAX_TURNS: 4 + GH_AW_MAX_TURNS: 3 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 }} @@ -805,7 +823,16 @@ jobs: - name: Copy Copilot session state files to logs if: always() continue-on-error: true - run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi - name: Stop MCP Gateway if: always() continue-on-error: true diff --git a/.github/workflows/contribution-check.md b/.github/workflows/contribution-check.md index 437abddfb..a675137c7 100644 --- a/.github/workflows/contribution-check.md +++ b/.github/workflows/contribution-check.md @@ -11,7 +11,7 @@ permissions: contents: read pull-requests: read issues: read -max-turns: 4 +max-turns: 3 concurrency: group: "contribution-check-${{ github.event.pull_request.number || github.ref }}" cancel-in-progress: true diff --git a/containers/api-proxy/guards/common-guard-checks.js b/containers/api-proxy/guards/common-guard-checks.js index 1a2a774a6..b989ad6b1 100644 --- a/containers/api-proxy/guards/common-guard-checks.js +++ b/containers/api-proxy/guards/common-guard-checks.js @@ -80,10 +80,8 @@ function buildCommonGuardChecks(deps, model) { { block: getMaxRunsBlockState(), isBlocked: block => block && block.maxExceeded, - // Terminal hard cap: 429 signals the agent has exhausted its allowed - // turns. Unlike transient rate-limits, the Retry-After is absent so - // well-behaved clients won't retry indefinitely. - statusCode: 429, + // Terminal hard cap — non-retryable (see effective-tokens guard above). + statusCode: 403, eventName: 'max_runs_exceeded', buildError: buildMaxRunsExceededError, buildLogFields: block => ({ diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index 6f8142713..cc0209f2f 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -231,7 +231,7 @@ describe('proxyRequest max-runs guard', () => { jest.restoreAllMocks(); }); - it('returns 429 with structured payload when max runs limit is exceeded', async () => { + it('returns 403 with structured payload when max runs limit is exceeded', async () => { const cycle = createMockUpstreamCycle(https); // First request completes successfully — consumes the single allowed run @@ -251,12 +251,11 @@ describe('proxyRequest max-runs guard', () => { await flushPromises(); expect(cycle.spy).toHaveBeenCalledTimes(1); - expect(res2.writeHead).toHaveBeenCalledWith(429, expect.objectContaining({ + expect(res2.writeHead).toHaveBeenCalledWith(403, expect.objectContaining({ 'Content-Type': 'application/json', })); const payload = JSON.parse(res2.end.mock.calls[0][0]); expect(payload.error.type).toBe('max_runs_exceeded'); - expect(payload.error.message).toMatch(/Maximum LLM invocations exceeded/); expect(payload.error.max_runs).toBe(1); expect(payload.error.invocation_count).toBe(1); }); diff --git a/containers/api-proxy/server.websocket.test.js b/containers/api-proxy/server.websocket.test.js index ad3ef1165..7dc86b242 100644 --- a/containers/api-proxy/server.websocket.test.js +++ b/containers/api-proxy/server.websocket.test.js @@ -323,14 +323,14 @@ describe('proxyWebSocket security guards', () => { jest.restoreAllMocks(); }); - it('blocks with 429 when max-runs limit is exceeded', () => { + it('blocks with 403 when max-runs limit is exceeded', () => { process.env.AWF_MAX_RUNS = '1'; applyMaxRunsInvocation(); // consume the single allowed run const socket = makeMockSocket(); wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 429 Too Many Requests')); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 403 Forbidden')); expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('"max_runs_exceeded"')); expect(socket.destroy).toHaveBeenCalled(); }); diff --git a/scripts/ci/contribution-check-workflow.test.ts b/scripts/ci/contribution-check-workflow.test.ts index b729db34a..7f61c24db 100644 --- a/scripts/ci/contribution-check-workflow.test.ts +++ b/scripts/ci/contribution-check-workflow.test.ts @@ -34,7 +34,7 @@ describe('contribution-check workflow', () => { it('has conservative turn limit and appropriate model', () => { const source = fs.readFileSync(sourcePath, 'utf-8'); - expect(source).toContain('max-turns: 4'); + expect(source).toContain('max-turns: 3'); expect(source).toContain('model: gpt-5.4-mini'); }); });