From 35dcabf342996012843bd3e6c6dd98422fffc3e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:53:37 +0000 Subject: [PATCH 1/5] jsweep: clean check_rate_limit.cjs - Extract PROGRAMMATIC_EVENTS as a module-level constant - Destructure context variables at top of main() - Simplify workflowId extraction using workflowRefMatch?.[1] - Use ?? instead of || for env var defaults - Simplify permission data access with nested destructuring - Use ?? for runsPerEvent accumulation - Add 2 new tests: runs without updated_at and multi-page pagination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/check_rate_limit.cjs | 50 +++++++++-------- actions/setup/js/check_rate_limit.test.cjs | 64 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index 1ed2bea9cf1..aa773c4a28d 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -9,26 +9,30 @@ const { fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs"); * Prevents users from triggering workflows too frequently */ +const PROGRAMMATIC_EVENTS = ["workflow_dispatch", "repository_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "discussion_comment"]; + async function main() { - const actor = context.actor; - const owner = context.repo.owner; - const repo = context.repo.repo; - const eventName = context.eventName; - const runId = context.runId; + const { + actor, + repo: { owner, repo }, + workflow, + eventName, + runId, + } = context; // Capture a rate-limit snapshot at the start of the check for observability. await fetchAndLogRateLimit(github, "check_rate_limit_start"); // Get workflow file name from GITHUB_WORKFLOW_REF (format: "owner/repo/.github/workflows/file.yml@ref") // or fall back to GITHUB_WORKFLOW (workflow name) - const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; - let workflowId = context.workflow; // Default to workflow name + const workflowRef = process.env.GITHUB_WORKFLOW_REF ?? ""; + // Extract workflow file from the ref (e.g., ".github/workflows/test.lock.yml@refs/heads/main") + const workflowRefMatch = workflowRef.match(/\.github\/workflows\/([^@]+)/); + let workflowId = workflow; // Default to workflow name if (workflowRef) { - // Extract workflow file from the ref (e.g., ".github/workflows/test.lock.yml@refs/heads/main") - const match = workflowRef.match(/\.github\/workflows\/([^@]+)/); - if (match && match[1]) { - workflowId = match[1]; + if (workflowRefMatch?.[1]) { + workflowId = workflowRefMatch[1]; core.info(` Using workflow file: ${workflowId} (from GITHUB_WORKFLOW_REF)`); } else { core.info(` Using workflow name: ${workflowId} (fallback - could not parse GITHUB_WORKFLOW_REF)`); @@ -38,11 +42,11 @@ async function main() { } // Get configuration from environment variables - const maxRuns = parseInt(process.env.GH_AW_RATE_LIMIT_MAX || "5", 10); - const windowMinutes = parseInt(process.env.GH_AW_RATE_LIMIT_WINDOW || "60", 10); - const eventsList = process.env.GH_AW_RATE_LIMIT_EVENTS || ""; + const maxRuns = parseInt(process.env.GH_AW_RATE_LIMIT_MAX ?? "5", 10); + const windowMinutes = parseInt(process.env.GH_AW_RATE_LIMIT_WINDOW ?? "60", 10); + const eventsList = process.env.GH_AW_RATE_LIMIT_EVENTS ?? ""; // Default: admin, maintain, and write roles are exempt from rate limiting - const ignoredRolesList = process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES || "admin,maintain,write"; + const ignoredRolesList = process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES ?? "admin,maintain,write"; core.info(`🔍 Checking rate limit for user '${actor}' on workflow '${workflowId}'`); core.info(` Configuration: max=${maxRuns} runs per ${windowMinutes} minutes`); @@ -54,14 +58,14 @@ async function main() { try { // Check user's permission level in the repository - const permResponse = await github.rest.repos.getCollaboratorPermissionLevel({ + const { + data: { permission: userPermission }, + } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: actor, }); - const { data: permissionData } = permResponse; - const userPermission = permissionData.permission; core.info(` User '${actor}' has permission level: ${userPermission}`); // Map GitHub permission levels to role names @@ -92,16 +96,14 @@ async function main() { } else { // When no specific events are configured, apply rate limiting only to // known programmatic triggers. Allow all other events. - const programmaticEvents = ["workflow_dispatch", "repository_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "discussion_comment"]; - - if (!programmaticEvents.includes(eventName)) { + if (!PROGRAMMATIC_EVENTS.includes(eventName)) { core.info(`✅ Event '${eventName}' is not a programmatic trigger; skipping rate limiting`); - core.info(` Rate limiting applies to: ${programmaticEvents.join(", ")}`); + core.info(` Rate limiting applies to: ${PROGRAMMATIC_EVENTS.join(", ")}`); core.setOutput("rate_limit_ok", "true"); return; } - core.info(` Rate limiting applies to programmatic events: ${programmaticEvents.join(", ")}`); + core.info(` Rate limiting applies to programmatic events: ${PROGRAMMATIC_EVENTS.join(", ")}`); } // Calculate time threshold @@ -197,7 +199,7 @@ async function main() { // Count this run totalRecentRuns++; - runsPerEvent[runEvent] = (runsPerEvent[runEvent] || 0) + 1; + runsPerEvent[runEvent] = (runsPerEvent[runEvent] ?? 0) + 1; core.info(` ✓ Run #${run.run_number} (${run.id}) by ${run.actor?.login} - ` + `event: ${runEvent}, created: ${run.created_at}, status: ${run.status}`); } diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs index f82dfaed667..3dfb359c7b4 100644 --- a/actions/setup/js/check_rate_limit.test.cjs +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -768,4 +768,68 @@ describe("check_rate_limit", () => { expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Stack trace:")); expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); }); + + it("should count runs without updated_at (no duration check applied)", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + // no updated_at — duration check skipped, run should be counted + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "in_progress", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 1")); + }); + + it("should fetch additional pages when first page is full", async () => { + process.env.GH_AW_RATE_LIMIT_MAX = "10"; + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + const makeRunOtherUser = id => ({ + id, + run_number: id, + created_at: recentTime.toISOString(), + actor: { login: "other-user" }, // not counted for test-user + event: "workflow_dispatch", + status: "completed", + }); + + const makeRunTestUser = id => ({ + id, + run_number: id, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }); + + // First page is full (100 runs) but all by a different user → no match, fetches page 2 + mockGithub.rest.actions.listWorkflowRuns + .mockResolvedValueOnce({ + data: { workflow_runs: Array.from({ length: 100 }, (_, i) => makeRunOtherUser(i + 1)) }, + }) + .mockResolvedValueOnce({ + data: { workflow_runs: [makeRunTestUser(101), makeRunTestUser(102)] }, + }); + + await checkRateLimit.main(); + + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalledTimes(2); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Fetching page 2")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 2")); + }); }); From a52e3432f00520530a0b562b0db789c56a5b1d63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 05:41:53 +0000 Subject: [PATCH 2/5] fix: set PROMPT_TEXT inside AWF container command for agent file imports When a custom agent file is used with the Claude engine and AWF (firewall) is enabled, AGENT_CONTENT and PROMPT_TEXT were previously set as host-side bash shell variables in PathSetup. Since unexported shell variables are not visible to subprocess environments, AWF's --env-all did not forward PROMPT_TEXT into the container, causing claude --print to receive an empty prompt and exit with: Error: Input must be provided either through stdin or as a prompt argument when using --print Fix: move the agent content setup (AGENT_CONTENT + PROMPT_TEXT assignment) into the AWF container command chain, matching the pattern already used by the Codex engine. The variables are now set inline before the claude invocation, in the same bash context where they are used. Non-AWF mode is unaffected: there the setup runs in the same bash process as the claude invocation so no export is needed. Adds TestClaudeEngineAgentFileAWFInlineSetup to prevent regression. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a57e18f-ce3e-4910-a7d7-a0fc08e602f4 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/design-decision-gate.lock.yml | 6 +- .github/workflows/hourly-ci-cleaner.lock.yml | 6 +- pkg/workflow/claude_engine.go | 68 +++++++++++++------ pkg/workflow/claude_engine_test.go | 58 ++++++++++++++++ 4 files changed, 108 insertions(+), 30 deletions(-) diff --git a/.github/workflows/design-decision-gate.lock.yml b/.github/workflows/design-decision-gate.lock.yml index 3444a755195..c8827c6bf4d 100644 --- a/.github/workflows/design-decision-gate.lock.yml +++ b/.github/workflows/design-decision-gate.lock.yml @@ -795,14 +795,10 @@ jobs: timeout-minutes: 15 run: | set -o pipefail - # Extract markdown body from custom agent file (skip frontmatter) - AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' "${GITHUB_WORKSPACE}/.github/agents/adr-writer.agent.md")" - # Combine agent content with prompt - PROMPT_TEXT="$(printf '%s\n\n%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" touch /tmp/gh-aw/agent-step-summary.md # shellcheck disable=SC1003 sudo -E awf --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" --tty --env-all --exclude-env ANTHROPIC_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,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,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.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,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash(cat),Bash(cat:*),Bash(date),Bash(echo),Bash(echo:*),Bash(find:*),Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git diff:*),Bash(git log:*),Bash(git merge:*),Bash(git rm:*),Bash(git show:*),Bash(git status),Bash(git switch:*),Bash(grep),Bash(grep:*),Bash(head),Bash(ls),Bash(ls:*),Bash(pwd),Bash(sort),Bash(tail),Bash(uniq),Bash(wc),Bash(wc:*),Bash(yq),BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && AGENT_CONTENT="$(awk '\''BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip'\'' "${GITHUB_WORKSPACE}/.github/agents/adr-writer.agent.md")" && PROMPT_TEXT="$(printf '\''%s\n\n%s'\'' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && claude --print --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash(cat),Bash(cat:*),Bash(date),Bash(echo),Bash(echo:*),Bash(find:*),Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git diff:*),Bash(git log:*),Bash(git merge:*),Bash(git rm:*),Bash(git show:*),Bash(git status),Bash(git switch:*),Bash(grep),Bash(grep:*),Bash(head),Bash(ls),Bash(ls:*),Bash(pwd),Bash(sort),Bash(tail),Bash(uniq),Bash(wc),Bash(wc:*),Bash(yq),BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASH_DEFAULT_TIMEOUT_MS: 60000 diff --git a/.github/workflows/hourly-ci-cleaner.lock.yml b/.github/workflows/hourly-ci-cleaner.lock.yml index aff47915c56..be833421260 100644 --- a/.github/workflows/hourly-ci-cleaner.lock.yml +++ b/.github/workflows/hourly-ci-cleaner.lock.yml @@ -752,14 +752,10 @@ jobs: timeout-minutes: 45 run: | set -o pipefail - # Extract markdown body from custom agent file (skip frontmatter) - AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' "${GITHUB_WORKSPACE}/.github/agents/ci-cleaner.agent.md")" - # Combine agent content with prompt - PROMPT_TEXT="$(printf '%s\n\n%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" touch /tmp/gh-aw/agent-step-summary.md # shellcheck disable=SC1003 sudo -E awf --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" --tty --env-all --exclude-env ANTHROPIC_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /opt/hostedtoolcache/go:/opt/hostedtoolcache/go:ro --mount /usr/bin/go:/usr/bin/go:ro --mount /usr/bin/make:/usr/bin/make:ro --mount /usr/local/bin/node:/usr/local/bin/node:ro --mount /usr/local/bin/npm:/usr/local/bin/npm:ro --mount /usr/local/lib/node_modules:/usr/local/lib/node_modules:ro --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,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,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,go.dev,golang.org,goproxy.io,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,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,storage.googleapis.com,sum.golang.org,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --no-chrome --max-turns 20 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && AGENT_CONTENT="$(awk '\''BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip'\'' "${GITHUB_WORKSPACE}/.github/agents/ci-cleaner.agent.md")" && PROMPT_TEXT="$(printf '\''%s\n\n%s'\'' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && claude --print --no-chrome --max-turns 20 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASH_DEFAULT_TIMEOUT_MS: 60000 diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index bab5f7b3f80..82c06c69e21 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -166,26 +166,42 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str claudeArgs = append(claudeArgs, workflowData.EngineConfig.Args...) } - // Build the agent command - prepend custom agent file content if specified (via imports) - var promptSetup string - var promptCommand string + // Resolve the agent file path early (used in both AWF and non-AWF paths below). + // An empty agentPath means no custom agent file was specified. + var agentPath string if workflowData.AgentFile != "" { - agentPath, err := ResolveAgentFilePath(workflowData.AgentFile) + var err error + agentPath, err = ResolveAgentFilePath(workflowData.AgentFile) if err != nil { claudeLog.Printf("Error resolving agent file path: %v", err) return BuildInvalidAgentPathStep("Execute Claude Code CLI", workflowData.AgentFile, err) } claudeLog.Printf("Using custom agent file: %s", workflowData.AgentFile) - // Extract markdown body from custom agent file and prepend to prompt - promptSetup = fmt.Sprintf(`# Extract markdown body from custom agent file (skip frontmatter) - AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" - # Combine agent content with prompt - PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, agentPath) + } + + // Build the prompt command. + // When an agent file is specified, the prompt is the concatenation of the agent file's + // markdown body (frontmatter stripped) and the workflow prompt. We always use the + // "$PROMPT_TEXT" shell variable reference here; the variable is set either inside the + // AWF container command (firewall path) or directly in the host run script (non-firewall + // path) — see the build sections below. + var promptCommand string + if agentPath != "" { promptCommand = `"$PROMPT_TEXT"` } else { promptCommand = `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"` } + // agentContainerSetup builds the inline shell fragment that sets AGENT_CONTENT and + // PROMPT_TEXT inside the execution environment. It is used in both the AWF container + // command and in the non-AWF host run script. + agentContainerSetup := func() string { + return fmt.Sprintf( + `AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" && PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, + agentPath, + ) + } + // Build the command string with proper argument formatting // Determine which command to use var commandName string @@ -239,16 +255,24 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // However, npm-installed CLIs (like claude) need hostedtoolcache bin directories in PATH. // We prepend GetNpmBinPathSetup() to the engine command so it runs inside the AWF container. npmPathSetup := GetNpmBinPathSetup() - claudeCommandWithPath := fmt.Sprintf(`%s && %s`, npmPathSetup, claudeCommand) - - // Build host-side path setup: create the agent step summary file so it is accessible - // inside the sandbox. Combine with any existing promptSetup (may be empty). - touchSummary := "touch " + AgentStepSummaryPath - hostSetup := touchSummary - if promptSetup != "" { - hostSetup = promptSetup + "\n" + touchSummary + + // When an agent file is used, AGENT_CONTENT and PROMPT_TEXT must be set *inside* the + // AWF container command. Setting them on the host (PathSetup) and relying on + // --env-all to forward them into the container does not work because unexported bash + // shell variables are not visible to subprocess environments, so $PROMPT_TEXT would + // expand to an empty string inside the container. Instead we inline the setup into + // the container command chain, exactly as the Codex engine does. + var claudeCommandWithPath string + if agentPath != "" { + claudeCommandWithPath = fmt.Sprintf(`%s && %s && %s`, npmPathSetup, agentContainerSetup(), claudeCommand) + } else { + claudeCommandWithPath = fmt.Sprintf(`%s && %s`, npmPathSetup, claudeCommand) } + // Host-side setup: only create the agent step summary file so it is accessible + // inside the sandbox. PROMPT_TEXT is no longer set here (see above). + hostSetup := "touch " + AgentStepSummaryPath + // Note: Claude Code CLI writes debug logs to --debug-file and JSON output to stdout // Use tee to capture stdout (stream-json output) to the log file while also displaying on console // The combined output (debug logs + JSON) will be in the log file for parsing @@ -259,7 +283,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str WorkflowData: workflowData, UsesTTY: true, // Claude Code CLI requires TTY AllowedDomains: allowedDomains, - PathSetup: hostSetup, // Runs BEFORE AWF on the host (prompt setup + summary file creation) + PathSetup: hostSetup, // Runs BEFORE AWF on the host (summary file creation only) // Exclude every env var whose step-env value is a secret so the agent // cannot read raw token values via bash tools (env / printenv). ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"ANTHROPIC_API_KEY"}), @@ -270,12 +294,16 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Use tee to capture stdout (stream-json output) to the log file while also displaying on console // The combined output (debug logs + JSON) will be in the log file for parsing // PATH is already set correctly by actions/setup-* steps which prepend to PATH - if promptSetup != "" { + if agentPath != "" { + // In non-AWF mode, set AGENT_CONTENT and PROMPT_TEXT in the same bash script — + // they are plain shell variables and are visible to the subsequent claude invocation + // in the same process. Unlike the AWF path there is no subprocess boundary here. command = fmt.Sprintf(`set -o pipefail touch %s + # Extract markdown body from custom agent file (skip frontmatter) %s # Execute Claude Code CLI with prompt from file - %s 2>&1 | tee -a %s`, AgentStepSummaryPath, promptSetup, claudeCommand, logFile) + %s 2>&1 | tee -a %s`, AgentStepSummaryPath, agentContainerSetup(), claudeCommand, logFile) } else { command = fmt.Sprintf(`set -o pipefail touch %s diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go index 8791d76dde1..70b06aad325 100644 --- a/pkg/workflow/claude_engine_test.go +++ b/pkg/workflow/claude_engine_test.go @@ -444,6 +444,64 @@ func TestClaudeEngineNoDoubleEscapePrompt(t *testing.T) { }) } +// TestClaudeEngineAgentFileAWFInlineSetup verifies that when an agent file is used with the +// firewall (AWF) enabled, AGENT_CONTENT and PROMPT_TEXT are set INSIDE the AWF container +// command rather than on the host. Setting them on the host would fail silently because +// unexported bash shell variables are not forwarded to container environments via --env-all. +func TestClaudeEngineAgentFileAWFInlineSetup(t *testing.T) { + engine := NewClaudeEngine() + + // Enable the firewall by providing a SandboxConfig with an AWF agent type (the simplest + // way to make isFirewallEnabled return true without a full compilation pass). + agentSandbox := &AgentSandboxConfig{Type: SandboxTypeAWF} + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + AgentFile: ".github/agents/test-agent.md", + SandboxConfig: &SandboxConfig{ + Agent: agentSandbox, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log") + if len(steps) == 0 { + t.Fatal("Expected at least one step") + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + // The host-side run script (PathSetup) must NOT contain PROMPT_TEXT assignment. + // In AWF mode the run: block contains only the touch + sudo awf invocation. + // We identify the host part as everything before the '-- /bin/bash -c' separator. + hostPart := stepContent + if idx := strings.Index(stepContent, "-- /bin/bash -c"); idx != -1 { + hostPart = stepContent[:idx] + } + if strings.Contains(hostPart, "PROMPT_TEXT=") { + t.Errorf("PROMPT_TEXT assignment found in host-side script (before AWF container command); it must be set inside the container:\n%s", hostPart) + } + if strings.Contains(hostPart, "AGENT_CONTENT=") { + t.Errorf("AGENT_CONTENT assignment found in host-side script (before AWF container command); it must be set inside the container:\n%s", hostPart) + } + + // The container command (after '-- /bin/bash -c') must contain the inline setup. + containerPart := "" + if idx := strings.Index(stepContent, "-- /bin/bash -c"); idx != -1 { + containerPart = stepContent[idx:] + } + if !strings.Contains(containerPart, "AGENT_CONTENT=") { + t.Errorf("Expected AGENT_CONTENT assignment inside the AWF container command, not found in:\n%s", containerPart) + } + if !strings.Contains(containerPart, "PROMPT_TEXT=") { + t.Errorf("Expected PROMPT_TEXT assignment inside the AWF container command, not found in:\n%s", containerPart) + } + if !strings.Contains(containerPart, `"$PROMPT_TEXT"`) { + t.Errorf("Expected claude to receive \"$PROMPT_TEXT\" inside the AWF container command, not found in:\n%s", containerPart) + } +} + func TestClaudeEngineSkipInstallationWithCommand(t *testing.T) { engine := NewClaudeEngine() From 5b8f42ae0772b7ce57125f6940bd9c2c919a4b96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 05:49:44 +0000 Subject: [PATCH 3/5] refactor: simplify agentSetupCmd to pre-computed string, fix lint Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a57e18f-ce3e-4910-a7d7-a0fc08e602f4 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/claude_engine.go | 15 ++++++++------- pkg/workflow/claude_engine_test.go | 10 +++------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 82c06c69e21..08ce55725fe 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -192,11 +192,12 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str promptCommand = `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"` } - // agentContainerSetup builds the inline shell fragment that sets AGENT_CONTENT and - // PROMPT_TEXT inside the execution environment. It is used in both the AWF container - // command and in the non-AWF host run script. - agentContainerSetup := func() string { - return fmt.Sprintf( + // agentSetupCmd is the inline shell fragment that sets AGENT_CONTENT and PROMPT_TEXT. + // It is used in both the AWF container command and in the non-AWF host run script. + // Empty when no agent file is specified. + var agentSetupCmd string + if agentPath != "" { + agentSetupCmd = fmt.Sprintf( `AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" && PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, agentPath, ) @@ -264,7 +265,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // the container command chain, exactly as the Codex engine does. var claudeCommandWithPath string if agentPath != "" { - claudeCommandWithPath = fmt.Sprintf(`%s && %s && %s`, npmPathSetup, agentContainerSetup(), claudeCommand) + claudeCommandWithPath = fmt.Sprintf(`%s && %s && %s`, npmPathSetup, agentSetupCmd, claudeCommand) } else { claudeCommandWithPath = fmt.Sprintf(`%s && %s`, npmPathSetup, claudeCommand) } @@ -303,7 +304,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str # Extract markdown body from custom agent file (skip frontmatter) %s # Execute Claude Code CLI with prompt from file - %s 2>&1 | tee -a %s`, AgentStepSummaryPath, agentContainerSetup(), claudeCommand, logFile) + %s 2>&1 | tee -a %s`, AgentStepSummaryPath, agentSetupCmd, claudeCommand, logFile) } else { command = fmt.Sprintf(`set -o pipefail touch %s diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go index 70b06aad325..12d45055681 100644 --- a/pkg/workflow/claude_engine_test.go +++ b/pkg/workflow/claude_engine_test.go @@ -475,9 +475,9 @@ func TestClaudeEngineAgentFileAWFInlineSetup(t *testing.T) { // The host-side run script (PathSetup) must NOT contain PROMPT_TEXT assignment. // In AWF mode the run: block contains only the touch + sudo awf invocation. // We identify the host part as everything before the '-- /bin/bash -c' separator. - hostPart := stepContent - if idx := strings.Index(stepContent, "-- /bin/bash -c"); idx != -1 { - hostPart = stepContent[:idx] + hostPart, containerPart, found := strings.Cut(stepContent, "-- /bin/bash -c") + if !found { + t.Fatal("Expected '-- /bin/bash -c' separator in step content (AWF mode marker), but it was not found") } if strings.Contains(hostPart, "PROMPT_TEXT=") { t.Errorf("PROMPT_TEXT assignment found in host-side script (before AWF container command); it must be set inside the container:\n%s", hostPart) @@ -487,10 +487,6 @@ func TestClaudeEngineAgentFileAWFInlineSetup(t *testing.T) { } // The container command (after '-- /bin/bash -c') must contain the inline setup. - containerPart := "" - if idx := strings.Index(stepContent, "-- /bin/bash -c"); idx != -1 { - containerPart = stepContent[idx:] - } if !strings.Contains(containerPart, "AGENT_CONTENT=") { t.Errorf("Expected AGENT_CONTENT assignment inside the AWF container command, not found in:\n%s", containerPart) } From 24f47cc55cbd7dc9f40f78a3c5d23f312b438e4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:09:14 +0000 Subject: [PATCH 4/5] chore: merge main, resolve conflict in check_rate_limit.cjs, recompile Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5c9c5d28-b49a-456e-9639-21fe2a9d1865 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/agentdrain/data/default_weights.json | 56 ++++-------------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/pkg/agentdrain/data/default_weights.json b/pkg/agentdrain/data/default_weights.json index f232d2e905d..74ea4e2b819 100644 --- a/pkg/agentdrain/data/default_weights.json +++ b/pkg/agentdrain/data/default_weights.json @@ -100,12 +100,7 @@ ], "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -151,21 +146,12 @@ "id": 1, "size": 100, "stage": "finish", - "template": [ - "stage=finish", - "\u003c*\u003e", - "tokens=\u003cNUM\u003e" - ] + "template": ["stage=finish", "\u003c*\u003e", "tokens=\u003cNUM\u003e"] } ], "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -211,21 +197,12 @@ "id": 1, "size": 58, "stage": "plan", - "template": [ - "stage=plan", - "errors=\u003cNUM\u003e", - "turns=\u003cNUM\u003e" - ] + "template": ["stage=plan", "errors=\u003cNUM\u003e", "turns=\u003cNUM\u003e"] } ], "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -269,12 +246,7 @@ "clusters": null, "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -318,12 +290,7 @@ "clusters": null, "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -1448,12 +1415,7 @@ ], "config": { "Depth": 4, - "ExcludeFields": [ - "session_id", - "trace_id", - "span_id", - "timestamp" - ], + "ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"], "MaskRules": [ { "Name": "uuid", @@ -1493,4 +1455,4 @@ }, "next_id": 15 } -} \ No newline at end of file +} From 9aa0d39359639299e46aff97d998872dcdb9a5f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:30:43 +0000 Subject: [PATCH 5/5] feat: add SupportsNativeAgentFile capability; remove native agent file handling from Claude engine Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1ef7cc2a-41f8-4054-ac51-de536a26ad76 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/design-decision-gate.lock.yml | 2 +- .github/workflows/hourly-ci-cleaner.lock.yml | 2 +- pkg/workflow/agentic_engine.go | 12 ++ pkg/workflow/claude_engine.go | 83 ++----------- pkg/workflow/claude_engine_test.go | 71 ++++++------ pkg/workflow/codex_engine.go | 1 + pkg/workflow/compiler_yaml.go | 9 ++ pkg/workflow/engine_agent_import_test.go | 109 ++++++++++++++---- 8 files changed, 157 insertions(+), 132 deletions(-) diff --git a/.github/workflows/design-decision-gate.lock.yml b/.github/workflows/design-decision-gate.lock.yml index e121ad5ef2e..8b144e8eccc 100644 --- a/.github/workflows/design-decision-gate.lock.yml +++ b/.github/workflows/design-decision-gate.lock.yml @@ -800,7 +800,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md # shellcheck disable=SC1003 sudo -E awf --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" --tty --env-all --exclude-env ANTHROPIC_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,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,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.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,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && AGENT_CONTENT="$(awk '\''BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip'\'' "${GITHUB_WORKSPACE}/.github/agents/adr-writer.agent.md")" && PROMPT_TEXT="$(printf '\''%s\n\n%s'\'' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && claude --print --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash(cat),Bash(cat:*),Bash(date),Bash(echo),Bash(echo:*),Bash(find:*),Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git diff:*),Bash(git log:*),Bash(git merge:*),Bash(git rm:*),Bash(git show:*),Bash(git status),Bash(git switch:*),Bash(grep),Bash(grep:*),Bash(head),Bash(ls),Bash(ls:*),Bash(pwd),Bash(sort),Bash(tail),Bash(uniq),Bash(wc),Bash(wc:*),Bash(yq),BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash(cat),Bash(cat:*),Bash(date),Bash(echo),Bash(echo:*),Bash(find:*),Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git diff:*),Bash(git log:*),Bash(git merge:*),Bash(git rm:*),Bash(git show:*),Bash(git status),Bash(git switch:*),Bash(grep),Bash(grep:*),Bash(head),Bash(ls),Bash(ls:*),Bash(pwd),Bash(sort),Bash(tail),Bash(uniq),Bash(wc),Bash(wc:*),Bash(yq),BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASH_DEFAULT_TIMEOUT_MS: 60000 diff --git a/.github/workflows/hourly-ci-cleaner.lock.yml b/.github/workflows/hourly-ci-cleaner.lock.yml index 3bde1201e89..c1e41d441cf 100644 --- a/.github/workflows/hourly-ci-cleaner.lock.yml +++ b/.github/workflows/hourly-ci-cleaner.lock.yml @@ -757,7 +757,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md # shellcheck disable=SC1003 sudo -E awf --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" --tty --env-all --exclude-env ANTHROPIC_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /opt/hostedtoolcache/go:/opt/hostedtoolcache/go:ro --mount /usr/bin/go:/usr/bin/go:ro --mount /usr/bin/make:/usr/bin/make:ro --mount /usr/local/bin/node:/usr/local/bin/node:ro --mount /usr/local/bin/npm:/usr/local/bin/npm:ro --mount /usr/local/lib/node_modules:/usr/local/lib/node_modules:ro --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,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,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,go.dev,golang.org,goproxy.io,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,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,storage.googleapis.com,sum.golang.org,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && AGENT_CONTENT="$(awk '\''BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip'\'' "${GITHUB_WORKSPACE}/.github/agents/ci-cleaner.agent.md")" && PROMPT_TEXT="$(printf '\''%s\n\n%s'\'' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && claude --print --no-chrome --max-turns 20 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$PROMPT_TEXT"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --no-chrome --max-turns 20 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASH_DEFAULT_TIMEOUT_MS: 60000 diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 0772cc7d486..d5f14bf85df 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -116,6 +116,13 @@ type CapabilityProvider interface { // SupportsMaxContinuations returns true if this engine supports the max-continuations feature // When true, max-continuations > 1 enables autopilot/multi-run mode for the engine SupportsMaxContinuations() bool + + // SupportsNativeAgentFile returns true if this engine handles agent-file imports natively + // in its own execution steps (reading the file, stripping frontmatter, and prepending the + // content to the prompt at runtime). When false, the compiler is responsible for including + // the agent file content in prompt.txt during the activation job so that the engine just + // reads the standard /tmp/gh-aw/aw-prompts/prompt.txt as usual. + SupportsNativeAgentFile() bool } // WorkflowExecutor handles workflow compilation and execution @@ -260,6 +267,7 @@ type BaseEngine struct { supportsMaxTurns bool supportsMaxContinuations bool supportsWebSearch bool + supportsNativeAgentFile bool llmGatewayPort int } @@ -295,6 +303,10 @@ func (e *BaseEngine) SupportsMaxContinuations() bool { return e.supportsMaxContinuations } +func (e *BaseEngine) SupportsNativeAgentFile() bool { + return e.supportsNativeAgentFile +} + func (e *BaseEngine) getLLMGatewayPort() int { return e.llmGatewayPort } diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 08ce55725fe..ed0ba5a4d5d 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -29,6 +29,7 @@ func NewClaudeEngine() *ClaudeEngine { supportsMaxTurns: true, // Claude supports max-turns feature supportsMaxContinuations: false, // Claude Code does not support --max-autopilot-continues-style continuation supportsWebSearch: true, // Claude has built-in WebSearch support + supportsNativeAgentFile: false, // Claude does not support agent file natively; the compiler prepends the agent file content to prompt.txt llmGatewayPort: constants.ClaudeLLMGatewayPort, }, } @@ -166,42 +167,11 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str claudeArgs = append(claudeArgs, workflowData.EngineConfig.Args...) } - // Resolve the agent file path early (used in both AWF and non-AWF paths below). - // An empty agentPath means no custom agent file was specified. - var agentPath string - if workflowData.AgentFile != "" { - var err error - agentPath, err = ResolveAgentFilePath(workflowData.AgentFile) - if err != nil { - claudeLog.Printf("Error resolving agent file path: %v", err) - return BuildInvalidAgentPathStep("Execute Claude Code CLI", workflowData.AgentFile, err) - } - claudeLog.Printf("Using custom agent file: %s", workflowData.AgentFile) - } - - // Build the prompt command. - // When an agent file is specified, the prompt is the concatenation of the agent file's - // markdown body (frontmatter stripped) and the workflow prompt. We always use the - // "$PROMPT_TEXT" shell variable reference here; the variable is set either inside the - // AWF container command (firewall path) or directly in the host run script (non-firewall - // path) — see the build sections below. - var promptCommand string - if agentPath != "" { - promptCommand = `"$PROMPT_TEXT"` - } else { - promptCommand = `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"` - } - - // agentSetupCmd is the inline shell fragment that sets AGENT_CONTENT and PROMPT_TEXT. - // It is used in both the AWF container command and in the non-AWF host run script. - // Empty when no agent file is specified. - var agentSetupCmd string - if agentPath != "" { - agentSetupCmd = fmt.Sprintf( - `AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" && PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, - agentPath, - ) - } + // The prompt is always read from prompt.txt, which is assembled by the compiler in the + // activation job. For engines that do not support native agent-file handling (including + // Claude), the compiler prepends the agent file content to prompt.txt so no special + // shell variable juggling is needed here. + promptCommand := `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"` // Build the command string with proper argument formatting // Determine which command to use @@ -219,8 +189,8 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Join command parts (excluding the prompt) with proper escaping. // The prompt command is appended raw after shellJoinArgs because it contains - // shell variable references ("$PROMPT_TEXT", "$(cat ...)") that must NOT be - // escaped — single-quoting them would prevent shell expansion at runtime. + // shell variable references ("$(cat ...)") that must NOT be escaped — + // single-quoting them would prevent shell expansion at runtime. claudeCommand := fmt.Sprintf("%s %s", shellJoinArgs(commandParts), promptCommand) // When model is not configured, use the GH_AW_MODEL_AGENT_CLAUDE fallback env var @@ -256,27 +226,8 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // However, npm-installed CLIs (like claude) need hostedtoolcache bin directories in PATH. // We prepend GetNpmBinPathSetup() to the engine command so it runs inside the AWF container. npmPathSetup := GetNpmBinPathSetup() + claudeCommandWithPath := fmt.Sprintf(`%s && %s`, npmPathSetup, claudeCommand) - // When an agent file is used, AGENT_CONTENT and PROMPT_TEXT must be set *inside* the - // AWF container command. Setting them on the host (PathSetup) and relying on - // --env-all to forward them into the container does not work because unexported bash - // shell variables are not visible to subprocess environments, so $PROMPT_TEXT would - // expand to an empty string inside the container. Instead we inline the setup into - // the container command chain, exactly as the Codex engine does. - var claudeCommandWithPath string - if agentPath != "" { - claudeCommandWithPath = fmt.Sprintf(`%s && %s && %s`, npmPathSetup, agentSetupCmd, claudeCommand) - } else { - claudeCommandWithPath = fmt.Sprintf(`%s && %s`, npmPathSetup, claudeCommand) - } - - // Host-side setup: only create the agent step summary file so it is accessible - // inside the sandbox. PROMPT_TEXT is no longer set here (see above). - hostSetup := "touch " + AgentStepSummaryPath - - // Note: Claude Code CLI writes debug logs to --debug-file and JSON output to stdout - // Use tee to capture stdout (stream-json output) to the log file while also displaying on console - // The combined output (debug logs + JSON) will be in the log file for parsing command = BuildAWFCommand(AWFCommandConfig{ EngineName: "claude", EngineCommand: claudeCommandWithPath, // Command with npm PATH setup runs inside AWF @@ -284,7 +235,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str WorkflowData: workflowData, UsesTTY: true, // Claude Code CLI requires TTY AllowedDomains: allowedDomains, - PathSetup: hostSetup, // Runs BEFORE AWF on the host (summary file creation only) + PathSetup: "touch " + AgentStepSummaryPath, // Runs BEFORE AWF on the host // Exclude every env var whose step-env value is a secret so the agent // cannot read raw token values via bash tools (env / printenv). ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"ANTHROPIC_API_KEY"}), @@ -295,22 +246,10 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Use tee to capture stdout (stream-json output) to the log file while also displaying on console // The combined output (debug logs + JSON) will be in the log file for parsing // PATH is already set correctly by actions/setup-* steps which prepend to PATH - if agentPath != "" { - // In non-AWF mode, set AGENT_CONTENT and PROMPT_TEXT in the same bash script — - // they are plain shell variables and are visible to the subsequent claude invocation - // in the same process. Unlike the AWF path there is no subprocess boundary here. - command = fmt.Sprintf(`set -o pipefail - touch %s - # Extract markdown body from custom agent file (skip frontmatter) - %s - # Execute Claude Code CLI with prompt from file - %s 2>&1 | tee -a %s`, AgentStepSummaryPath, agentSetupCmd, claudeCommand, logFile) - } else { - command = fmt.Sprintf(`set -o pipefail + command = fmt.Sprintf(`set -o pipefail touch %s # Execute Claude Code CLI with prompt from file %s 2>&1 | tee -a %s`, AgentStepSummaryPath, claudeCommand, logFile) - } } // Build environment variables map diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go index 12d45055681..3169cdc93bd 100644 --- a/pkg/workflow/claude_engine_test.go +++ b/pkg/workflow/claude_engine_test.go @@ -392,7 +392,9 @@ func TestClaudeEngineWithSafeOutputs(t *testing.T) { } } -// TestClaudeEngineNoDoubleEscapePrompt tests that the prompt argument is not double-escaped +// TestClaudeEngineNoDoubleEscapePrompt tests that the prompt argument is not double-escaped. +// Claude always reads the prompt from prompt.txt; agent-file content is prepended there by +// the compiler rather than being handled in the engine step. func TestClaudeEngineNoDoubleEscapePrompt(t *testing.T) { engine := NewClaudeEngine() @@ -419,8 +421,9 @@ func TestClaudeEngineNoDoubleEscapePrompt(t *testing.T) { } }) - // Test with agent file (custom prompt) - t.Run("with_agent_file", func(t *testing.T) { + // Test with agent file: Claude still reads from prompt.txt (compiler prepended the agent + // file content there); no PROMPT_TEXT shell variable should appear in the step. + t.Run("with_agent_file_uses_prompt_txt", func(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", EngineConfig: &EngineConfig{ @@ -432,27 +435,33 @@ func TestClaudeEngineNoDoubleEscapePrompt(t *testing.T) { steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") stepContent := strings.Join([]string(steps[0]), "\n") - // Should have single-quoted PROMPT_TEXT, not double-quoted - if strings.Contains(stepContent, `""$PROMPT_TEXT""`) { - t.Errorf("Found double-escaped PROMPT_TEXT variable (with double quotes), expected single quotes:\n%s", stepContent) + // Must still read from prompt.txt — not from a PROMPT_TEXT shell variable + if !strings.Contains(stepContent, `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`) { + t.Errorf("Expected claude to read from prompt.txt even with agent file set, got:\n%s", stepContent) } - - // Should have correctly quoted PROMPT_TEXT - if !strings.Contains(stepContent, `"$PROMPT_TEXT"`) { - t.Errorf("Expected correctly quoted PROMPT_TEXT variable, got:\n%s", stepContent) + if strings.Contains(stepContent, "PROMPT_TEXT") { + t.Errorf("Claude must not use a PROMPT_TEXT shell variable when an agent file is set; compiler handles the prepending:\n%s", stepContent) } }) } -// TestClaudeEngineAgentFileAWFInlineSetup verifies that when an agent file is used with the -// firewall (AWF) enabled, AGENT_CONTENT and PROMPT_TEXT are set INSIDE the AWF container -// command rather than on the host. Setting them on the host would fail silently because -// unexported bash shell variables are not forwarded to container environments via --env-all. -func TestClaudeEngineAgentFileAWFInlineSetup(t *testing.T) { +// TestClaudeEngineDoesNotSupportNativeAgentFile verifies that the Claude engine declares +// it does not handle agent files natively, so the compiler knows to prepend the agent file +// content to prompt.txt during the activation job instead. +func TestClaudeEngineDoesNotSupportNativeAgentFile(t *testing.T) { + engine := NewClaudeEngine() + if engine.SupportsNativeAgentFile() { + t.Errorf("Claude engine should return false for SupportsNativeAgentFile(); the compiler handles agent file injection") + } +} + +// TestClaudeEngineAWFWithAgentFileReadsPromptTxt verifies that when an agent file is used +// with the firewall (AWF) enabled, the claude command reads from prompt.txt (not from a +// PROMPT_TEXT shell variable). The compiler prepends the agent file content to prompt.txt +// in the activation job. +func TestClaudeEngineAWFWithAgentFileReadsPromptTxt(t *testing.T) { engine := NewClaudeEngine() - // Enable the firewall by providing a SandboxConfig with an AWF agent type (the simplest - // way to make isFirewallEnabled return true without a full compilation pass). agentSandbox := &AgentSandboxConfig{Type: SandboxTypeAWF} workflowData := &WorkflowData{ Name: "test-workflow", @@ -472,29 +481,17 @@ func TestClaudeEngineAgentFileAWFInlineSetup(t *testing.T) { stepContent := strings.Join([]string(steps[0]), "\n") - // The host-side run script (PathSetup) must NOT contain PROMPT_TEXT assignment. - // In AWF mode the run: block contains only the touch + sudo awf invocation. - // We identify the host part as everything before the '-- /bin/bash -c' separator. - hostPart, containerPart, found := strings.Cut(stepContent, "-- /bin/bash -c") - if !found { - t.Fatal("Expected '-- /bin/bash -c' separator in step content (AWF mode marker), but it was not found") - } - if strings.Contains(hostPart, "PROMPT_TEXT=") { - t.Errorf("PROMPT_TEXT assignment found in host-side script (before AWF container command); it must be set inside the container:\n%s", hostPart) + // No AGENT_CONTENT or PROMPT_TEXT shell variables anywhere in the step. + if strings.Contains(stepContent, "AGENT_CONTENT") { + t.Errorf("AGENT_CONTENT must not appear in the Claude AWF step; compiler handles agent file injection:\n%s", stepContent) } - if strings.Contains(hostPart, "AGENT_CONTENT=") { - t.Errorf("AGENT_CONTENT assignment found in host-side script (before AWF container command); it must be set inside the container:\n%s", hostPart) + if strings.Contains(stepContent, "PROMPT_TEXT") { + t.Errorf("PROMPT_TEXT must not appear in the Claude AWF step; compiler handles agent file injection:\n%s", stepContent) } - // The container command (after '-- /bin/bash -c') must contain the inline setup. - if !strings.Contains(containerPart, "AGENT_CONTENT=") { - t.Errorf("Expected AGENT_CONTENT assignment inside the AWF container command, not found in:\n%s", containerPart) - } - if !strings.Contains(containerPart, "PROMPT_TEXT=") { - t.Errorf("Expected PROMPT_TEXT assignment inside the AWF container command, not found in:\n%s", containerPart) - } - if !strings.Contains(containerPart, `"$PROMPT_TEXT"`) { - t.Errorf("Expected claude to receive \"$PROMPT_TEXT\" inside the AWF container command, not found in:\n%s", containerPart) + // The container command must still read from prompt.txt. + if !strings.Contains(stepContent, `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`) { + t.Errorf("Expected claude to read from prompt.txt in AWF mode, got:\n%s", stepContent) } } diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 568bf0815c1..18125c0ea2e 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -40,6 +40,7 @@ func NewCodexEngine() *CodexEngine { supportsMaxTurns: false, // Codex does not support max-turns feature supportsMaxContinuations: false, // Codex does not support --max-autopilot-continues-style continuation mode supportsWebSearch: true, // Codex has built-in web-search support + supportsNativeAgentFile: true, // Codex reads the agent file and prepends it to the prompt at runtime llmGatewayPort: constants.CodexLLMGatewayPort, }, } diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index b24ea4b6a1e..aef655f9b85 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -380,6 +380,15 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, pre // - Imported markdown with inputs is still inlined (compile-time substitution required) // - Main workflow markdown body uses runtime-import to allow editing without recompilation // This ensures consistency for most imports while maintaining import inputs functionality + // + // NOTE: When an engine does not support native agent-file handling + // (SupportsNativeAgentFile() == false), the agent file content is already present in the + // prompt via the standard mechanisms below — no special Step 0 is needed: + // - Agent files WITHOUT inputs: path is in data.ImportPaths → included by Step 1b. + // - Agent files WITH inputs: content is in data.ImportedMarkdown → included by Step 1a. + // - inlined-imports mode: data.AgentFile is cleared; content is in data.ImportPaths. + // Engines that DO support native agent-file handling (e.g. Codex) continue to read the + // file themselves in GetExecutionSteps alongside the runtime-import. var userPromptChunks []string var expressionMappings []*ExpressionMapping diff --git a/pkg/workflow/engine_agent_import_test.go b/pkg/workflow/engine_agent_import_test.go index 2c850cd80b6..b4be21eaee4 100644 --- a/pkg/workflow/engine_agent_import_test.go +++ b/pkg/workflow/engine_agent_import_test.go @@ -113,7 +113,9 @@ func TestCopilotEngineWithoutAgentFlag(t *testing.T) { } } -// TestClaudeEngineWithAgentFromImports tests that claude engine prepends agent file content to prompt +// TestClaudeEngineWithAgentFromImports tests that claude engine does NOT handle agent files +// natively — agent file content is prepended to prompt.txt by the compiler in the activation +// job, so the engine step always reads the standard prompt.txt path. func TestClaudeEngineWithAgentFromImports(t *testing.T) { engine := NewClaudeEngine() workflowData := &WorkflowData{ @@ -132,19 +134,26 @@ func TestClaudeEngineWithAgentFromImports(t *testing.T) { stepContent := strings.Join([]string(steps[0]), "\n") - // Check that custom agent content extraction is present - if !strings.Contains(stepContent, `AGENT_CONTENT="$(awk`) { - t.Errorf("Expected agent content extraction in claude command, got:\n%s", stepContent) + // Claude does not handle the agent file natively — no awk or AGENT_CONTENT/PROMPT_TEXT + // variable juggling should appear in the step. + if strings.Contains(stepContent, "AGENT_CONTENT") { + t.Errorf("Claude must NOT handle agent file natively (AGENT_CONTENT found in step); the compiler handles it:\n%s", stepContent) + } + if strings.Contains(stepContent, "awk") { + t.Errorf("Claude must NOT invoke awk for agent file reading (found in step); the compiler handles it:\n%s", stepContent) + } + if strings.Contains(stepContent, "PROMPT_TEXT") { + t.Errorf("Claude must NOT use a PROMPT_TEXT shell variable (found in step); the compiler handles it:\n%s", stepContent) } - // Check that agent file path is referenced with quoted GITHUB_WORKSPACE prefix - if !strings.Contains(stepContent, `"${GITHUB_WORKSPACE}/.github/agents/test-agent.md"`) { - t.Errorf("Expected agent file path with quoted GITHUB_WORKSPACE prefix in claude command, got:\n%s", stepContent) + // The engine still reads the standard prompt.txt (which has agent content prepended by the compiler). + if !strings.Contains(stepContent, `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`) { + t.Errorf("Expected standard prompt.txt reading in claude command, got:\n%s", stepContent) } - // Check that agent content is prepended to prompt - if !strings.Contains(stepContent, "$AGENT_CONTENT") { - t.Errorf("Expected $AGENT_CONTENT variable in claude command, got:\n%s", stepContent) + // The engine reports that it does not support native agent file handling. + if engine.SupportsNativeAgentFile() { + t.Errorf("Claude engine should return false for SupportsNativeAgentFile()") } } @@ -344,8 +353,11 @@ This is a test agent file. }) } -// TestInvalidAgentFilePathGeneratesFailingStep tests that engines emit a clearly-failing step -// (rather than silently skipping execution) when an agent file path contains shell metacharacters. +// TestInvalidAgentFilePathGeneratesFailingStep tests that engines that handle agent files +// natively emit a clearly-failing step (rather than silently skipping execution) when the +// agent file path contains shell metacharacters. +// Engines that do NOT support native agent files (e.g. Claude) rely on the compiler's +// validateAgentFile to reject malicious paths at compile time instead. func TestInvalidAgentFilePathGeneratesFailingStep(t *testing.T) { maliciousPath := `.github/agents/a";id;"b.md` @@ -373,7 +385,10 @@ func TestInvalidAgentFilePathGeneratesFailingStep(t *testing.T) { } }) - t.Run("claude_emits_failing_step_for_invalid_path", func(t *testing.T) { + // Claude does not handle agent files natively; path validation is done by the compiler + // at compile time (validateAgentFile). The engine step should proceed normally and never + // reference the agent file path directly. + t.Run("claude_ignores_agent_path_in_step_for_invalid_path", func(t *testing.T) { engine := NewClaudeEngine() workflowData := &WorkflowData{ Name: "test-workflow", @@ -382,18 +397,15 @@ func TestInvalidAgentFilePathGeneratesFailingStep(t *testing.T) { steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") if len(steps) != 1 { - t.Fatalf("Expected exactly 1 failing step, got %d", len(steps)) + t.Fatalf("Expected exactly 1 step, got %d", len(steps)) } content := strings.Join([]string(steps[0]), "\n") - if !strings.Contains(content, "exit 1") { - t.Errorf("Expected failing step with 'exit 1', got:\n%s", content) - } - if !strings.Contains(content, "Error") { - t.Errorf("Expected error message in failing step, got:\n%s", content) + // Must NOT reference the malicious path at all in the generated step + if strings.Contains(content, maliciousPath) { + t.Errorf("Claude step must not reference the agent file path directly, got:\n%s", content) } - // Must NOT invoke awk (that would mean the path was used for real execution) if strings.Contains(content, "awk") { - t.Errorf("Failing step must not invoke awk with the invalid path, got:\n%s", content) + t.Errorf("Claude step must not invoke awk for agent file reading, got:\n%s", content) } }) } @@ -466,3 +478,58 @@ func TestCheckoutWithAgentFromImports(t *testing.T) { } }) } + +// TestCompilerIncludesAgentFileViaImportPaths verifies that when a non-native engine (Claude) +// is used with an agent file, the agent file path is included in the prompt via the standard +// ImportPaths/runtime-import mechanism (Step 1b in generatePrompt), so that prompt.txt +// already contains the agent file content when the engine reads it. +func TestCompilerIncludesAgentFileViaImportPaths(t *testing.T) { + agentFilePath := ".github/agents/my-agent.md" + + tmpDir := t.TempDir() + workflowFile := filepath.Join(tmpDir, ".github", "workflows", "test.md") + if err := os.MkdirAll(filepath.Dir(workflowFile), 0o755); err != nil { + t.Fatalf("Failed to create workflow directory: %v", err) + } + if err := os.WriteFile(workflowFile, []byte("# Do the thing\n"), 0o644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Simulate what the orchestrator populates: the agent file is in ImportPaths (no inputs). + workflowData := &WorkflowData{ + Name: "test-workflow", + AI: "claude", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + AgentFile: agentFilePath, + // ImportPaths mirrors what import_bfs.go populates for agent files without inputs. + ImportPaths: []string{agentFilePath}, + } + + compiler := NewCompiler() + compiler.markdownPath = workflowFile + + var buf strings.Builder + compiler.generatePrompt(&buf, workflowData, false, nil) + generated := buf.String() + + // The runtime-import macro for the agent file must appear in the generated YAML (exactly once). + agentImportMacro := "{{#runtime-import " + agentFilePath + "}}" + count := strings.Count(generated, agentImportMacro) + if count == 0 { + t.Errorf("Expected runtime-import macro %q in generated prompt YAML, got:\n%s", agentImportMacro, generated) + } else if count > 1 { + t.Errorf("Expected runtime-import macro %q exactly once, but found %d occurrences:\n%s", agentImportMacro, count, generated) + } + + // The agent file import must appear before the main workflow markdown import. + mainWorkflowMacro := "{{#runtime-import .github/workflows/test.md}}" + agentIdx := strings.Index(generated, agentImportMacro) + mainIdx := strings.Index(generated, mainWorkflowMacro) + if mainIdx == -1 { + t.Errorf("Expected main workflow runtime-import macro %q in generated prompt YAML, got:\n%s", mainWorkflowMacro, generated) + } else if agentIdx > mainIdx { + t.Errorf("Agent file runtime-import macro must appear before main workflow macro in prompt:\n%s", generated) + } +}