diff --git a/setup/clean.sh b/setup/clean.sh index 1eca8c3..3f3cd51 100755 --- a/setup/clean.sh +++ b/setup/clean.sh @@ -40,3 +40,21 @@ if [ -d "${tmpDir}" ]; then echo "Warning: failed to clean up ${tmpDir}" >&2 fi fi + +# Remove AWF chroot home directories under /tmp (e.g. /tmp/awf-*-chroot-home). +# These are created by AWF when running with --enable-host-access on GitHub-hosted runners. +# Files inside may be owned by root (written by Docker containers or privileged AWF processes), +# causing EACCES failures if cleanup is attempted without sudo. +if awf_chroot_home_dirs="$(sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -print 2>/dev/null)"; then + if [ -n "${awf_chroot_home_dirs}" ]; then + if sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null; then + echo "Cleaned up /tmp/awf-*-chroot-home directories (sudo)" + else + echo "Warning: failed to clean /tmp/awf-*-chroot-home directories" >&2 + fi + else + echo "No /tmp/awf-*-chroot-home directories found" + fi +else + echo "Warning: unable to inspect /tmp/awf-*-chroot-home directories with sudo" >&2 +fi diff --git a/setup/js/add_workflow_run_comment.cjs b/setup/js/add_workflow_run_comment.cjs index eb57234..b3a29c9 100644 --- a/setup/js/add_workflow_run_comment.cjs +++ b/setup/js/add_workflow_run_comment.cjs @@ -171,6 +171,18 @@ function reportCommentError(rawContext, message) { core.setFailed(message); } +/** + * @param {Record|null} awContext + * @param {string} key + * @returns {string} + */ +function readAwContextString(awContext, key) { + if (!awContext || typeof awContext[key] !== "string") { + return ""; + } + return awContext[key].trim(); +} + /** * @param {ReusableStatusComment} reusableComment * @param {{ @@ -184,8 +196,11 @@ function reportCommentError(rawContext, message) { * @returns {Promise} */ async function updateReusableStatusComment(reusableComment, invocationContext, rawContext) { - const runUrl = buildWorkflowRunUrl(rawContext, invocationContext.workflowRepo); - const commentBody = buildCommentBody(invocationContext.eventName, runUrl); + const awContext = extractAwContextFromPayload(rawContext?.payload); + const dispatchedRunUrl = readAwContextString(awContext, "dispatched_run_url"); + const dispatchedWorkflowName = readAwContextString(awContext, "dispatched_workflow_name"); + const runUrl = dispatchedRunUrl || buildWorkflowRunUrl(rawContext, invocationContext.workflowRepo); + const commentBody = buildCommentBody(invocationContext.eventName, runUrl, dispatchedWorkflowName || undefined); // Discussion comments use GraphQL node IDs and a dedicated update mutation. if (reusableComment.id.startsWith("DC_")) { @@ -337,10 +352,13 @@ async function main() { * Sanitizes the content and appends all required markers. * @param {string} eventName - The event type * @param {string} runUrl - The URL of the workflow run + * @param {string} [workflowNameOverride] - Optional dispatched workflow name override * @returns {string} The assembled comment body */ -function buildCommentBody(eventName, runUrl) { - const workflowName = process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow"; +function buildCommentBody(eventName, runUrl, workflowNameOverride) { + // Whitespace-only overrides are treated as absent and fall back to env defaults. + const normalizedWorkflowNameOverride = workflowNameOverride?.trim(); + const workflowName = normalizedWorkflowNameOverride || process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow"; const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; // Sanitize before adding markers (defense in depth for custom message templates) diff --git a/setup/js/ai_credits_context.cjs b/setup/js/ai_credits_context.cjs index ac5eb7b..01ecef0 100644 --- a/setup/js/ai_credits_context.cjs +++ b/setup/js/ai_credits_context.cjs @@ -235,10 +235,7 @@ function parseMaxAICreditsExceededFromAuditEntry(entry) { if (key === "reason") reason = value; if (key === "forced_termination") forcedTermination = value; } - if (typeof event === "string" && event === BUDGET_EXCEEDED_EVENT && typeof reason === "string" && reason === "hard_limit" && isTrueLike(forcedTermination)) { - return true; - } - return false; + return typeof event === "string" && event === BUDGET_EXCEEDED_EVENT && typeof reason === "string" && reason === "hard_limit" && isTrueLike(forcedTermination); } /** @@ -315,6 +312,23 @@ function parseAuditLogCombined(auditJsonlPathOverride) { }); } +/** + * Logs the provenance source and value for a single AI credits field. + * Outputs exactly one line regardless of which source resolved the value. + * + * @param {string} label + * @param {string} auditValue + * @param {string} stdioValue + * @param {string} envValue + * @param {string} envVarName + */ +function logAICreditSource(label, auditValue, stdioValue, envValue, envVarName) { + if (auditValue) console.log(`[ai-credits] ${label} source=audit_log value=${auditValue}`); + else if (stdioValue) console.log(`[ai-credits] ${label} source=agent_stdio value=${stdioValue}`); + else if (envValue) console.log(`[ai-credits] ${label} source=env(${envVarName}) value=${envValue}`); + else console.log(`[ai-credits] ${label} source=none ${envVarName}=${process.env[envVarName] || "(unset)"}`); +} + /** * @param {{ logProvenance?: boolean }} [options] * @returns {{ aiCredits: string, maxAICredits: string, aiCreditsRateLimitError: boolean, maxAICreditsExceeded: boolean }} @@ -329,25 +343,8 @@ function resolveAICreditsFailureState({ logProvenance = true } = {}) { // Log provenance so failing issues can be diagnosed when credit data is missing. if (logProvenance) { - if (auditAICredits) { - console.log(`[ai-credits] aiCredits source=audit_log value=${auditAICredits}`); - } else if (stdioSignals.aiCredits) { - console.log(`[ai-credits] aiCredits source=agent_stdio value=${stdioSignals.aiCredits}`); - } else if (envAICredits) { - console.log(`[ai-credits] aiCredits source=env(GH_AW_AIC) value=${envAICredits}`); - } else { - console.log(`[ai-credits] aiCredits source=none GH_AW_AIC=${process.env.GH_AW_AIC || "(unset)"}`); - } - - if (auditMaxAICredits) { - console.log(`[ai-credits] maxAICredits source=audit_log value=${auditMaxAICredits}`); - } else if (stdioSignals.maxAICredits) { - console.log(`[ai-credits] maxAICredits source=agent_stdio value=${stdioSignals.maxAICredits}`); - } else if (envMaxAICredits) { - console.log(`[ai-credits] maxAICredits source=env(GH_AW_MAX_AI_CREDITS) value=${envMaxAICredits}`); - } else { - console.log(`[ai-credits] maxAICredits source=none GH_AW_MAX_AI_CREDITS=${process.env.GH_AW_MAX_AI_CREDITS || "(unset)"}`); - } + logAICreditSource("aiCredits", auditAICredits, stdioSignals.aiCredits, envAICredits, "GH_AW_AIC"); + logAICreditSource("maxAICredits", auditMaxAICredits, stdioSignals.maxAICredits, envMaxAICredits, "GH_AW_MAX_AI_CREDITS"); const rawRateLimitSignalSource = auditRateLimitError ? "audit_log" diff --git a/setup/js/apply_samples.cjs b/setup/js/apply_samples.cjs index f73c0ae..8005d31 100644 --- a/setup/js/apply_samples.cjs +++ b/setup/js/apply_samples.cjs @@ -223,10 +223,19 @@ async function derivePrHeadRef(entry) { return directRef.trim(); } - // Determine the target repo for any API lookups. Prefer the entry's repo if - // the sample sets one (cross-repo workflows), otherwise fall back to - // GITHUB_REPOSITORY. - const repoSlug = (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) || process.env.GITHUB_REPOSITORY || ""; + // Determine the target repo for any API lookups. + // Resolution order: + // a. entry.arguments.repo — explicit per-sample override (cross-repo workflows). + // b. target-repo from the safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH) + // for the tool — covers siderepo workflow_dispatch where the sample arguments + // carry `pull_request_number` but not a `repo` override (issue #41292). + // c. GITHUB_REPOSITORY — host repo fallback. + let repoSlug = ""; + if (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) { + repoSlug = entry.arguments.repo.trim(); + } else { + repoSlug = readConfiguredTargetRepo(entry.tool) || process.env.GITHUB_REPOSITORY || ""; + } const [owner, repo] = repoSlug.split("/"); if (!owner || !repo) return null; diff --git a/setup/js/chroot_home_cleanup.test.js b/setup/js/chroot_home_cleanup.test.js new file mode 100644 index 0000000..5903cfa --- /dev/null +++ b/setup/js/chroot_home_cleanup.test.js @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const POST_SCRIPT_PATH = path.join(__dirname, "..", "post.js"); +const CLEAN_SCRIPT_PATH = path.join(__dirname, "..", "clean.sh"); + +const tempDirs = []; + +function createTempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createFakeSudoEnvironment() { + const root = createTempDir("chroot-home-cleanup-"); + const fakeBin = path.join(root, "fake-bin"); + fs.mkdirSync(fakeBin, { recursive: true }); + + const logPath = path.join(root, "sudo.log"); + const fakeSudoPath = path.join(fakeBin, "sudo"); + fs.writeFileSync( + fakeSudoPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$FAKE_SUDO_LOG" +if [ "$1" = "find" ]; then + if printf '%s\\n' "$*" | grep -q -- '-print'; then + printf '%b' "\${FAKE_FIND_PRINT_OUTPUT:-}" + exit "\${FAKE_FIND_PRINT_STATUS:-0}" + fi + if printf '%s\\n' "$*" | grep -q -- '-exec'; then + exit "\${FAKE_FIND_EXEC_STATUS:-0}" + fi +fi +exit 0 +`, + { mode: 0o755 } + ); + + return { + fakeBin, + logPath, + root, + }; +} + +function runPostScript(env) { + return spawnSync(process.execPath, [POST_SCRIPT_PATH], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +function runCleanScript(env) { + return spawnSync("bash", [CLEAN_SCRIPT_PATH], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir && fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("post.js chroot-home cleanup", () => { + it("logs that no directories were found when find output is empty", () => { + const { fakeBin, logPath } = createFakeSudoEnvironment(); + const result = runPostScript({ + PATH: `${fakeBin}:${process.env.PATH}`, + FAKE_SUDO_LOG: logPath, + FAKE_FIND_PRINT_OUTPUT: "", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("No /tmp/awf-*-chroot-home directories found"); + expect(fs.readFileSync(logPath, "utf8")).not.toContain("-exec rm -rf -- {} +"); + }); + + it("logs count of cleaned chroot-home directories", () => { + const { fakeBin, logPath } = createFakeSudoEnvironment(); + const result = runPostScript({ + PATH: `${fakeBin}:${process.env.PATH}`, + FAKE_SUDO_LOG: logPath, + FAKE_FIND_PRINT_OUTPUT: "/tmp/awf-1-chroot-home\n/tmp/awf-2-chroot-home\n", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Cleaned up 2 /tmp/awf-*-chroot-home directories"); + expect(fs.readFileSync(logPath, "utf8")).toContain("-exec rm -rf -- {} +"); + }); +}); + +describe("clean.sh chroot-home cleanup", () => { + it("logs when no chroot-home directories are found", () => { + const { fakeBin, logPath, root } = createFakeSudoEnvironment(); + const destination = path.join(root, "destination"); + fs.mkdirSync(destination, { recursive: true }); + + const result = runCleanScript({ + PATH: `${fakeBin}:${process.env.PATH}`, + FAKE_SUDO_LOG: logPath, + FAKE_FIND_PRINT_OUTPUT: "", + INPUT_DESTINATION: destination, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("No /tmp/awf-*-chroot-home directories found"); + expect(fs.readFileSync(logPath, "utf8")).not.toContain("-exec rm -rf -- {} +"); + }); + + it("logs successful cleanup when chroot-home directories are found", () => { + const { fakeBin, logPath, root } = createFakeSudoEnvironment(); + const destination = path.join(root, "destination"); + fs.mkdirSync(destination, { recursive: true }); + + const result = runCleanScript({ + PATH: `${fakeBin}:${process.env.PATH}`, + FAKE_SUDO_LOG: logPath, + FAKE_FIND_PRINT_OUTPUT: "/tmp/awf-1-chroot-home\n", + INPUT_DESTINATION: destination, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Cleaned up /tmp/awf-*-chroot-home directories (sudo)"); + expect(fs.readFileSync(logPath, "utf8")).toContain("-exec rm -rf -- {} +"); + }); +}); diff --git a/setup/js/copilot_harness.cjs b/setup/js/copilot_harness.cjs index e44ad64..2b13542 100644 --- a/setup/js/copilot_harness.cjs +++ b/setup/js/copilot_harness.cjs @@ -83,6 +83,9 @@ const OUTPUT_TAIL_MAX_LINES = 12; const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md"; // Pattern to detect transient CAPIError 400 in copilot output const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/; +// Pattern to detect generic HTTP 400 Bad Request responses emitted by engine CLI / SDK wrappers. +// NOTE: keep in sync with HTTP_400_RESPONSE_ERROR_PATTERN in detect_agent_errors.cjs. +const HTTP_400_RESPONSE_ERROR_PATTERN = /Response status code does not indicate success:\s*400(?:\s*\(Bad Request\))?/i; // Pattern to detect MCP servers blocked by enterprise/organization policy. // This is a persistent policy configuration error — retrying will not help. @@ -161,6 +164,15 @@ function isTransientCAPIError(output) { return CAPI_ERROR_400_PATTERN.test(output); } +/** + * Determines if the collected output contains a generic HTTP 400 response failure. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isHTTP400ResponseError(output) { + return HTTP_400_RESPONSE_ERROR_PATTERN.test(output); +} + /** * Determines if the collected output indicates MCP servers were blocked by policy. * This is a persistent configuration error that cannot be resolved by retrying. @@ -325,6 +337,7 @@ function extractOutputTail(output, options) { * isMCPGatewayShutdown?: boolean, * isMCPPolicy?: boolean, * isModelNotSupported?: boolean, + * isHTTP400ResponseError?: boolean, * isNullTypeToolCall?: boolean, * isQuotaExceeded?: boolean, * isSDKSessionIdleTimeout?: boolean, @@ -336,6 +349,7 @@ function classifyCopilotFailure(detection) { if (detection.isQuotaExceeded) return "capi_quota_exceeded"; if (detection.isMCPPolicy) return "mcp_policy_blocked"; if (detection.isModelNotSupported) return "model_not_supported"; + if (detection.isHTTP400ResponseError) return "http_400_response_error"; if (detection.isNullTypeToolCall) return "null_type_tool_call"; if (detection.isAuthErr) return "no_auth_info"; if (detection.isAuthenticationFailed) return "authentication_failed"; @@ -465,7 +479,7 @@ function isRetryableProxyAuthenticationFailure(output, hasOutput) { /** * Detect known Copilot error patterns for workflow outputs. * @param {string} output - * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean }} + * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean }} */ function detectCopilotErrors(output) { return { @@ -473,12 +487,13 @@ function detectCopilotErrors(output) { mcpPolicyError: isMCPPolicyError(output), agenticEngineTimeout: AGENTIC_ENGINE_TIMEOUT_PATTERN.test(output), modelNotSupportedError: isModelNotSupportedError(output), + http400ResponseError: isHTTP400ResponseError(output), }; } /** * Write Copilot detection outputs to $GITHUB_OUTPUT. - * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean }} results + * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean }} results */ function writeCopilotOutputs(results) { const outputFile = process.env.GITHUB_OUTPUT; @@ -492,6 +507,7 @@ function writeCopilotOutputs(results) { `mcp_policy_error=${results.mcpPolicyError}`, `agentic_engine_timeout=${results.agenticEngineTimeout}`, `model_not_supported_error=${results.modelNotSupportedError}`, + `http_400_response_error=${results.http400ResponseError}`, ]; fs.appendFileSync(outputFile, lines.join("\n") + "\n"); } @@ -754,6 +770,7 @@ async function main() { mcpPolicyError: false, agenticEngineTimeout: false, modelNotSupportedError: false, + http400ResponseError: false, }; /** @type {Awaited>} */ let copilotSDKServer = null; @@ -811,6 +828,7 @@ async function main() { detectedCopilotErrors.mcpPolicyError ||= attemptDetections.mcpPolicyError; detectedCopilotErrors.agenticEngineTimeout ||= attemptDetections.agenticEngineTimeout; detectedCopilotErrors.modelNotSupportedError ||= attemptDetections.modelNotSupportedError; + detectedCopilotErrors.http400ResponseError ||= attemptDetections.http400ResponseError; // Success — record exit code and stop retrying if (result.exitCode === 0) { @@ -835,6 +853,7 @@ async function main() { const isQuotaExceeded = isCAPIQuotaExceededError(result.output); const isMCPPolicy = isMCPPolicyError(result.output); const isModelNotSupported = isModelNotSupportedError(result.output); + const hasHTTP400ResponseError = isHTTP400ResponseError(result.output); const isAuthErr = isNoAuthInfoError(result.output); const isAuthenticationFailed = isAuthenticationFailedError(result.output); const proxyAuthDiagnostic = buildCopilotProxyAuthFailureDiagnostic(result.output, process.env); @@ -852,6 +871,7 @@ async function main() { isMCPGatewayShutdown, isMCPPolicy, isModelNotSupported, + isHTTP400ResponseError: hasHTTP400ResponseError, isNullTypeToolCall, isQuotaExceeded, isSDKSessionIdleTimeout, @@ -866,6 +886,7 @@ async function main() { ` isCAPIQuotaExceededError=${isQuotaExceeded}` + ` isMCPPolicyError=${isMCPPolicy}` + ` isModelNotSupportedError=${isModelNotSupported}` + + ` isHTTP400ResponseError=${hasHTTP400ResponseError}` + ` isNullTypeToolCallError=${isNullTypeToolCall}` + ` isSDKSessionIdleTimeoutError=${isSDKSessionIdleTimeout}` + ` isMCPGatewayShutdownError=${isMCPGatewayShutdown}` + @@ -955,6 +976,19 @@ async function main() { break; } + // Generic HTTP 400 response errors are usually persistent request/state failures. + // Retry once as a fresh run to discard potentially stale conversation state. + if (hasHTTP400ResponseError) { + if (attempt < MAX_RETRIES && result.hasOutput && useContinueOnRetry) { + useContinueOnRetry = false; + continueDisabledPermanently = true; + log(`attempt ${attempt + 1}: HTTP 400 response error on --continue — retrying once as fresh run (request/state may be stale; --continue disabled permanently)`); + continue; + } + log(`attempt ${attempt + 1}: HTTP 400 response error — not retrying (persistent request validation/state failure)`); + break; + } + // Auth error: behavior depends on whether this was a --continue attempt (CLI mode only). // On a --continue attempt: the Copilot CLI's on-disk session credential written by the // interrupted run may be incomplete/invalid. Fall back to a fresh run (without --continue) @@ -1069,6 +1103,7 @@ if (typeof module !== "undefined" && module.exports) { hasNoopInSafeOutputs, hasExpectedSafeOutputs, isDetectionPhase, + isHTTP400ResponseError, isModelAvailableInReflectData, isModelAvailableInReflectFile, resolveCopilotSDKCustomProviderFromReflect, diff --git a/setup/js/detect_agent_errors.cjs b/setup/js/detect_agent_errors.cjs index 4791b2a..9a49161 100644 --- a/setup/js/detect_agent_errors.cjs +++ b/setup/js/detect_agent_errors.cjs @@ -16,6 +16,8 @@ * - model_not_supported_error: The configured model is invalid or unsupported * for the selected engine/account (for example unknown model name, model not * found, or model unavailable for the plan). + * - http_400_response_error: The engine surfaced a generic HTTP 400 Bad Request + * response (for example "Response status code does not indicate success: 400 (Bad Request)"). * - capi_quota_exceeded_error: The Copilot CAPI quota has been exhausted * or rate-limited (e.g., "CAPIError: 429 429 quota exceeded", * "CAPIError: Too Many Requests"). All matched forms are treated as @@ -61,6 +63,10 @@ const AGENTIC_ENGINE_TIMEOUT_PATTERN = /signal=SIG(?:TERM|KILL|INT)/; const MODEL_NOT_SUPPORTED_PATTERN = /(?:The requested model is not supported|invalid model(?:\s+name)?\s+['"`]?[a-z0-9._:/@-]+['"`]?(?=(?:\s*$|\s*[\n\r.,;:!?)]))|unknown model\s+['"`]?[a-z0-9._:/@-]+['"`]?(?=(?:\s*$|\s*[\n\r.,;:!?)]))|model(?:\s+name)?\s+['"`]?[a-z0-9._:/@-]+['"`]?\s+(?:is\s+)?(?:not found|does not exist|not supported|not available|unavailable)|404\b[^\n]*\bModel\s+not\s+found)/i; +// Pattern: Generic HTTP 400 Bad Request responses emitted by engine / SDK wrappers. +// NOTE: keep in sync with HTTP_400_RESPONSE_ERROR_PATTERN in copilot_harness.cjs. +const HTTP_400_RESPONSE_ERROR_PATTERN = /Response status code does not indicate success:\s*400(?:\s*\(Bad Request\))?/i; + // Pattern: Copilot/CAPI quota exhaustion and rate-limit responses. // Matches all observed forms: // "CAPIError: 429 429 quota exceeded" (original observed form) @@ -82,7 +88,7 @@ function isCAPIQuotaExceededError(output) { /** * Detect known error patterns in a log string and return detection results. * @param {string} logContent - Contents of the agent stdio log - * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, capiQuotaExceededError: boolean }} + * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean }} */ function detectErrors(logContent) { return { @@ -90,13 +96,14 @@ function detectErrors(logContent) { mcpPolicyError: MCP_POLICY_BLOCKED_PATTERN.test(logContent), agenticEngineTimeout: AGENTIC_ENGINE_TIMEOUT_PATTERN.test(logContent), modelNotSupportedError: MODEL_NOT_SUPPORTED_PATTERN.test(logContent), + http400ResponseError: HTTP_400_RESPONSE_ERROR_PATTERN.test(logContent), capiQuotaExceededError: isCAPIQuotaExceededError(logContent), }; } /** * Write GitHub Actions outputs to $GITHUB_OUTPUT. - * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, capiQuotaExceededError: boolean }} results + * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean }} results */ function writeOutputs(results) { const outputFile = process.env.GITHUB_OUTPUT; @@ -110,6 +117,7 @@ function writeOutputs(results) { `mcp_policy_error=${results.mcpPolicyError}`, `agentic_engine_timeout=${results.agenticEngineTimeout}`, `model_not_supported_error=${results.modelNotSupportedError}`, + `http_400_response_error=${results.http400ResponseError}`, `capi_quota_exceeded_error=${results.capiQuotaExceededError}`, ]; fs.appendFileSync(outputFile, lines.join("\n") + "\n"); @@ -138,6 +146,9 @@ function main() { if (results.modelNotSupportedError) { process.stderr.write("[detect-agent-errors] Detected model configuration error: configured model is invalid or unavailable for this engine/account\n"); } + if (results.http400ResponseError) { + process.stderr.write("[detect-agent-errors] Detected HTTP 400 response error in agent log\n"); + } if (results.capiQuotaExceededError) { process.stderr.write("[detect-agent-errors] Detected CAPI quota exhaustion: Copilot quota has been exceeded\n"); } @@ -149,4 +160,13 @@ if (require.main === module) { main(); } -module.exports = { detectErrors, isCAPIQuotaExceededError, INFERENCE_ACCESS_ERROR_PATTERN, MCP_POLICY_BLOCKED_PATTERN, AGENTIC_ENGINE_TIMEOUT_PATTERN, MODEL_NOT_SUPPORTED_PATTERN, CAPI_QUOTA_EXCEEDED_PATTERN }; +module.exports = { + detectErrors, + isCAPIQuotaExceededError, + INFERENCE_ACCESS_ERROR_PATTERN, + MCP_POLICY_BLOCKED_PATTERN, + AGENTIC_ENGINE_TIMEOUT_PATTERN, + MODEL_NOT_SUPPORTED_PATTERN, + HTTP_400_RESPONSE_ERROR_PATTERN, + CAPI_QUOTA_EXCEEDED_PATTERN, +}; diff --git a/setup/js/generate_aw_info.cjs b/setup/js/generate_aw_info.cjs index ba091d3..1343c6e 100644 --- a/setup/js/generate_aw_info.cjs +++ b/setup/js/generate_aw_info.cjs @@ -123,6 +123,11 @@ async function main(core, ctx) { awInfo.token_weights = tokenWeights; } + const features = parseFeaturesFromEnv(core); + if (features) { + awInfo.features = features; + } + // Include aw_context when the workflow was triggered by a caller that relayed // orchestration context via workflow inputs or repository_dispatch client payload. // Validates JSON format and structure before populating the context key in aw_info.json. @@ -190,6 +195,29 @@ async function main(core, ctx) { } } + /** + * Parse optional features map from GH_AW_INFO_FEATURES. + * @param {typeof import('@actions/core')} core + * @returns {Record | null} + */ + function parseFeaturesFromEnv(core) { + const featuresEnv = process.env.GH_AW_INFO_FEATURES; + if (!featuresEnv) { + return null; + } + try { + const parsed = JSON.parse(featuresEnv); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return Object.keys(parsed).length > 0 ? parsed : null; + } + core.warning("GH_AW_INFO_FEATURES must be a JSON object, ignoring"); + return null; + } catch { + core.warning(`Failed to parse GH_AW_INFO_FEATURES: ${featuresEnv}`); + return null; + } + } + core.info("Generated aw_info.json at: " + tmpPath); core.info(JSON.stringify(awInfo, null, 2)); diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 8e410f0..87b0df0 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -14,6 +14,7 @@ const { AWF_INFRA_LINE_RE } = require("./log_parser_shared.cjs"); const { resolveFirewallAuditLogPath, resolveAICreditsFailureState, parseMaxAICreditsFromAuditLog, parseAICreditsErrorInfoFromAuditLog, parseUnknownModelAICreditsFromAuditLog } = require("./ai_credits_context.cjs"); const { formatAICCredits } = require("./daily_aic_workflow_helpers.cjs"); const { formatAIC } = require("./model_costs.cjs"); +const { parseBoolTemplatable } = require("./templatable.cjs"); const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp_gateway_log.cjs"); const { readDedupedTokenUsage, TOKEN_USAGE_PATHS } = require("./parse_token_usage.cjs"); const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); @@ -210,6 +211,7 @@ function buildFailureMatchCategories(options) { if (options.inferenceAccessError) categories.push("inference_access_error"); if (options.mcpPolicyError) categories.push("mcp_policy_error"); if (options.modelNotSupportedError) categories.push("model_not_supported_error"); + if (options.http400ResponseError) categories.push("http_400_response_error"); if (options.aiCreditsRateLimitError) categories.push("ai_credits_rate_limit_error"); if (options.unknownModelAICredits) categories.push("unknown_model_ai_credits"); if (options.maxAICreditsExceeded) categories.push("max_ai_credits_exceeded"); @@ -245,6 +247,7 @@ function buildFailureMatchCategories(options) { * @param {boolean} options.aiCreditsRateLimitError * @param {boolean} options.maxAICreditsExceeded * @param {boolean} options.hasAssignmentErrors + * @param {boolean} options.http400ResponseError * @returns {string} */ function buildFailureIssueTitle(options) { @@ -252,6 +255,9 @@ function buildFailureIssueTitle(options) { if (options.hasDailyAICExceeded) return `[aw] ${workflowName} exceeded daily AI credits budget`; if (options.maxAICreditsExceeded) return `[aw] ${workflowName} exceeded max AI credits`; if (options.aiCreditsRateLimitError) return `[aw] ${workflowName} hit AI credits rate limit`; + // Keep HTTP 400 below AI-credits signals: quota/rate-limit indicates an account-level + // budget state that should take precedence when both classes are detected. + if (options.http400ResponseError) return `[aw] ${workflowName} hit HTTP 400 bad request`; if (options.hasAppTokenMintingFailed) return `[aw] ${workflowName} failed to mint GitHub App token`; if (options.hasLockdownCheckFailed) return `[aw] ${workflowName} failed lockdown check`; if (options.hasStaleLockFileFailed) return `[aw] ${workflowName} has stale lock file`; @@ -1590,6 +1596,19 @@ function buildModelNotSupportedErrorContext(hasModelNotSupportedError) { return "\n" + renderPromptTemplate("model_not_supported_error.md"); } +/** + * Build a context string when the agentic engine returned a generic HTTP 400 Bad Request response. + * @param {boolean} hasHTTP400ResponseError - Whether a generic HTTP 400 response error was detected + * @returns {string} Formatted context string, or empty string if no error + */ +function buildHTTP400ResponseErrorContext(hasHTTP400ResponseError) { + if (!hasHTTP400ResponseError) { + return ""; + } + + return "\n" + renderPromptTemplate("http_400_response_error.md"); +} + /** * Builds the unknown_model_ai_credits failure context block for templates. * @param {boolean} hasUnknownModelAICreditsError @@ -2688,11 +2707,12 @@ async function main() { const mcpPolicyError = process.env.GH_AW_MCP_POLICY_ERROR === "true"; const agenticEngineTimeout = process.env.GH_AW_AGENTIC_ENGINE_TIMEOUT === "true"; const modelNotSupportedError = process.env.GH_AW_MODEL_NOT_SUPPORTED_ERROR === "true"; + const http400ResponseError = process.env.GH_AW_HTTP_400_RESPONSE_ERROR === "true"; const unknownModelAICreditsFromOutput = process.env.GH_AW_UNKNOWN_MODEL_AI_CREDITS === "true"; const unknownModelAICreditsFromAudit = parseUnknownModelAICreditsFromAuditLog(); const unknownModelAICredits = unknownModelAICreditsFromAudit || (unknownModelAICreditsFromOutput && agentConclusion === "failure"); const pushRepoMemoryResult = process.env.GH_AW_PUSH_REPO_MEMORY_RESULT || ""; - const reportFailureAsIssue = process.env.GH_AW_FAILURE_REPORT_AS_ISSUE !== "false"; // Default to true + const reportFailureAsIssue = parseBoolTemplatable(process.env.GH_AW_FAILURE_REPORT_AS_ISSUE, true); // Parse included categories filter for report-failure-as-issue (optional JSON array of category strings) const failureCategoriesFilterRaw = process.env.GH_AW_FAILURE_CATEGORIES_FILTER || ""; let failureCategoriesFilter = null; @@ -2792,6 +2812,7 @@ async function main() { core.info(`MCP policy error: ${mcpPolicyError}`); core.info(`Agentic engine timeout: ${agenticEngineTimeout}`); core.info(`Model not supported error: ${modelNotSupportedError}`); + core.info(`HTTP 400 response error: ${http400ResponseError}`); core.info(`Unknown model AI credits error: ${unknownModelAICredits}`); core.info(`Unknown model AI credits sources (audit/output): ${unknownModelAICreditsFromAudit}/${unknownModelAICreditsFromOutput}`); core.info(`Push repo-memory result: ${pushRepoMemoryResult}`); @@ -3068,6 +3089,7 @@ async function main() { aiCreditsRateLimitError, maxAICreditsExceeded, hasAssignmentErrors, + http400ResponseError, }); const failureCategories = buildFailureMatchCategories({ agentConclusion, @@ -3088,6 +3110,7 @@ async function main() { inferenceAccessError, mcpPolicyError, modelNotSupportedError, + http400ResponseError, aiCreditsRateLimitError, unknownModelAICredits, maxAICreditsExceeded, @@ -3249,6 +3272,7 @@ async function main() { // Build model not supported error context const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError); + const http400ResponseErrorContext = buildHTTP400ResponseErrorContext(http400ResponseError); const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl); const unknownModelAICreditsContext = buildUnknownModelAICreditsContext(unknownModelAICredits); @@ -3296,6 +3320,7 @@ async function main() { inference_access_error_context: inferenceAccessErrorContext, mcp_policy_error_context: mcpPolicyErrorContext, model_not_supported_error_context: modelNotSupportedErrorContext, + http_400_response_error_context: http400ResponseErrorContext, ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext, unknown_model_ai_credits_context: unknownModelAICreditsContext, app_token_minting_failed_context: appTokenMintingFailedContext, @@ -3458,6 +3483,7 @@ async function main() { // Build model not supported error context const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError); + const http400ResponseErrorContext = buildHTTP400ResponseErrorContext(http400ResponseError); const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl); const unknownModelAICreditsContext = buildUnknownModelAICreditsContext(unknownModelAICredits); @@ -3509,6 +3535,7 @@ async function main() { inference_access_error_context: inferenceAccessErrorContext, mcp_policy_error_context: mcpPolicyErrorContext, model_not_supported_error_context: modelNotSupportedErrorContext, + http_400_response_error_context: http400ResponseErrorContext, ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext, unknown_model_ai_credits_context: unknownModelAICreditsContext, app_token_minting_failed_context: appTokenMintingFailedContext, @@ -3609,6 +3636,7 @@ module.exports = { buildReportIncompleteContext, buildMCPPolicyErrorContext, buildModelNotSupportedErrorContext, + buildHTTP400ResponseErrorContext, buildMissingDataContext, resolveCacheMemoryRestored, buildMissingToolContext, diff --git a/setup/js/log_parser_bootstrap.cjs b/setup/js/log_parser_bootstrap.cjs index 21bb562..2cd007a 100644 --- a/setup/js/log_parser_bootstrap.cjs +++ b/setup/js/log_parser_bootstrap.cjs @@ -297,8 +297,13 @@ async function runLogParser(options) { core.summary.addRaw(fullMarkdown).write(); } else { - // Fallback: just log success message for parsers without log entries - core.info(`${parserName} log parsed successfully`); + // Fallback path: markdown exists but no structured log entries were parsed. + // Suppress the "parsed successfully" message for Claude since it always produces + // logEntries when healthy — absence of entries means the parse fell back and is + // about to emit a guardrail warning/failure below. + if (parserName !== "Claude") { + core.info(`${parserName} log parsed successfully`); + } // Add safe outputs preview to core.info (fallback path) if (safeOutputsContent) { @@ -330,8 +335,17 @@ async function runLogParser(options) { // Claude-specific guardrail: if no structured log entries were parsed, treat as execution failure. // This catches silent startup failures where Claude exits before producing JSON tool activity. + // Exception: when safeOutputEntriesCount > 0 the agent demonstrably completed and emitted + // safe outputs — treat as a non-fatal post-completion infrastructure failure (e.g. sandbox + // teardown race leaving agent-stdio.log unreadable) and downgrade to a warning. if (parserName === "Claude" && (!logEntries || logEntries.length === 0)) { - core.setFailed(`${ERR_CONFIG}: Claude execution failed: no structured log entries were produced. This usually indicates a startup or configuration error before tool execution.`); + if (safeOutputEntriesCount > 0) { + core.warning( + `Claude produced no structured log entries, but agent completed with ${safeOutputEntriesCount} safe output ${safeOutputEntriesCount === 1 ? "entry" : "entries"} — treating as non-fatal post-completion infrastructure failure` + ); + } else { + core.setFailed(`${ERR_CONFIG}: Claude execution failed: no structured log entries were produced. This usually indicates a startup or configuration error before tool execution.`); + } } // Handle MCP server failures if present diff --git a/setup/js/log_parser_format.cjs b/setup/js/log_parser_format.cjs index 43fa01d..2ae3fa2 100644 --- a/setup/js/log_parser_format.cjs +++ b/setup/js/log_parser_format.cjs @@ -58,6 +58,28 @@ function createLogParserFormatters(deps) { const INTERNAL_TOOLS = ["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"]; + /** + * Selects an outer markdown code fence that is longer than any backtick run + * present in the rendered content, so nested code fences in agent output + * cannot prematurely close the wrapper fence. + * @param {string[]} contentLines + * @returns {string} + */ + function buildSafeOuterCodeFence(contentLines) { + let maxBacktickRun = 0; + for (const line of contentLines) { + const text = String(line ?? ""); + const runRe = /`+/g; + let match; + while ((match = runRe.exec(text)) !== null) { + if (match[0].length > maxBacktickRun) { + maxBacktickRun = match[0].length; + } + } + } + return "`".repeat(Math.max(3, maxBacktickRun + 1)); + } + function normalizeEntriesForRendering(logEntries) { if (isCopilotEventLogEntries(logEntries)) { return convertCopilotEventsToLegacyLogEntries(logEntries); @@ -623,14 +645,12 @@ function createLogParserFormatters(deps) { */ function generateCopilotCliStyleSummary(logEntries, options = {}) { const lines = []; + const bodyLines = ["Conversation:", "", ...generateSummaryLines(logEntries)]; + const fence = buildSafeOuterCodeFence(bodyLines); - lines.push("```"); - lines.push("Conversation:"); - lines.push(""); - - lines.push(...generateSummaryLines(logEntries)); - - lines.push("```"); + lines.push(fence); + lines.push(...bodyLines); + lines.push(fence); return lines.join("\n"); } diff --git a/setup/js/log_parser_shared.cjs b/setup/js/log_parser_shared.cjs index e48cc87..ce18773 100644 --- a/setup/js/log_parser_shared.cjs +++ b/setup/js/log_parser_shared.cjs @@ -923,15 +923,42 @@ function convertCopilotEventsToLegacyLogEntries(logEntries) { } const success = typeof data.success === "boolean" ? data.success : !data.error; + // Order of precedence for structured result payloads: + // 1) direct text/content fields + // 2) json payloads + // 3) serialized object fallback + const extractResultContentText = value => { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return ""; + if (typeof value.text === "string") return value.text; + if (typeof value.content === "string") return value.content; + if (value.type === "json" && value.json !== undefined) { + try { + return JSON.stringify(value.json, null, 2); + } catch { + return String(value.json); + } + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + }; + let output = ""; if (typeof data.output === "string") { output = data.output; } else if (typeof data.result === "string") { output = data.result; - } else if (data.result && typeof data.result.content === "string") { + } else if (data.result && data.result.content !== undefined && data.result.content !== null) { // Native Copilot CLI events.jsonl format: result.content is the concise - // tool result text sent to the LLM (may be truncated for token efficiency). - output = data.result.content; + // tool result payload sent to the LLM (may be truncated for token efficiency). + if (Array.isArray(data.result.content)) { + output = data.result.content.map(extractResultContentText).filter(Boolean).join("\n"); + } else if (typeof data.result.content === "string" || typeof data.result.content === "object") { + output = extractResultContentText(data.result.content); + } } else if (data.error) { output = typeof data.error === "object" && typeof data.error.message === "string" ? data.error.message : String(data.error); } else if (success) { diff --git a/setup/js/messages_footer.cjs b/setup/js/messages_footer.cjs index 1420c01..71a72ef 100644 --- a/setup/js/messages_footer.cjs +++ b/setup/js/messages_footer.cjs @@ -143,6 +143,7 @@ function getAICFromEnv() { * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) * @property {string} [slashCommand] - Slash command name (without leading slash) for the run-again hint, when applicable * @property {string} [slashCommandPlaceholder] - Custom hint text appended after the command name (replaces default "to run again") + * @property {string} [labelCommand] - Label command name for the run-again hint, when applicable */ /** @@ -203,9 +204,26 @@ function getFooterMessage(ctx) { threatDetectionAiCreditsSuffix, }); - // Use custom footer template if configured (no automatic suffix appended) + const getRunAgainHints = renderedFooter => { + let hints = ""; + if (ctx.slashCommand) { + const hintText = ctx.slashCommandPlaceholder || "to run again"; + hints += `> Comment /{slash_command} ${hintText}`; + } + if (ctx.labelCommand) { + if (hints) hints += "\n"; + hints += `> Add label {label_command} to run again`; + } + const renderedHints = renderTemplate(hints, templateContext); + if (!renderedHints) return ""; + const separator = renderedFooter && !renderedFooter.endsWith("\n") ? "\n" : ""; + return separator + renderedHints; + }; + + // Use custom footer template if configured if (messages?.footer) { - return renderTemplate(messages.footer, templateContext); + const renderedCustomFooter = renderTemplate(messages.footer, templateContext); + return renderedCustomFooter + getRunAgainHints(renderedCustomFooter); } // Default footer template - includes emoji prefix when available @@ -229,12 +247,27 @@ function getFooterMessage(ctx) { if (ctx.historyUrl) { defaultFooter += " · [◷]({history_url})"; } - // Append slash command hint when applicable (workflow has a slash command trigger) - if (ctx.slashCommand) { - const hintText = ctx.slashCommandPlaceholder || "to run again"; - defaultFooter += `\n> Comment /{slash_command} ${hintText}`; + const renderedDefaultFooter = renderTemplate(defaultFooter, templateContext); + return renderedDefaultFooter + getRunAgainHints(renderedDefaultFooter); +} + +/** + * @param {string|undefined} commandsJSON + * @returns {string|undefined} + */ +function getFirstCommandHint(commandsJSON) { + if (!commandsJSON) { + return undefined; } - return renderTemplate(defaultFooter, templateContext); + try { + const commands = JSON.parse(commandsJSON); + if (Array.isArray(commands) && commands.length > 0 && typeof commands[0] === "string") { + return commands[0]; + } + } catch { + // Silently ignore malformed JSON; hints are non-critical enhancements. + } + return undefined; } /** @@ -580,20 +613,11 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl // Read slash command from GH_AW_COMMANDS (JSON array) when available. // Use the first command as the hint. This is only set when the workflow has a slash command trigger. - let slashCommand; - const commandsJSON = process.env.GH_AW_COMMANDS; - if (commandsJSON) { - try { - const commands = JSON.parse(commandsJSON); - if (Array.isArray(commands) && commands.length > 0 && typeof commands[0] === "string") { - slashCommand = commands[0]; - } - } catch { - // Silently ignore malformed GH_AW_COMMANDS; the hint is a non-critical enhancement - // and omitting it is always safe. The value is compiler-generated JSON, so this - // path should not occur in practice. - } - } + const slashCommand = getFirstCommandHint(process.env.GH_AW_COMMANDS); + + // Read label command from GH_AW_LABEL_COMMANDS (JSON array) when available. + // Use the first configured label as the hint. + const labelCommand = getFirstCommandHint(process.env.GH_AW_LABEL_COMMANDS); // Read optional footer hint placeholder from GH_AW_COMMAND_PLACEHOLDER. // When set, it replaces the default "to run again" suffix in the slash command hint. @@ -610,6 +634,7 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl emoji, slashCommand, slashCommandPlaceholder, + labelCommand, }; const { skipDetectionCaution = false } = options || {}; diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs index 4d9ebce..b7f4782 100644 --- a/setup/js/push_to_pull_request_branch.cjs +++ b/setup/js/push_to_pull_request_branch.cjs @@ -119,6 +119,38 @@ async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) .filter(Boolean); } +/** + * Checks if a git push stderr output indicates that the 'workflows' scope is required. + * GitHub rejects branch pushes that contain .github/workflows/** changes when the token + * lacks the 'workflows' scope, producing one of two known error message variants. + * + * @param {string} stderr - The captured stderr from a failed git push + * @returns {boolean} true when the rejection is due to missing 'workflows' scope + */ +function isWorkflowsScopeRejection(stderr) { + if (!stderr) return false; + const lower = stderr.toLowerCase(); + return lower.includes("`workflows` scope") || lower.includes("workflow can be created or updated due to timeout"); +} + +/** + * Builds the typed result and logs actionable guidance when a branch push fails + * because the token lacks the 'workflows' scope. + * + * @param {string} context - Short label identifying the push path (e.g. "Review branch", "Fallback branch") + * @param {typeof core} core - Actions core logger + * @returns {{ success: false, error_type: "workflows_scope_required", error: string }} + */ +function buildWorkflowsScopeError(context, core) { + core.error(`${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) that require the 'workflows' scope on the push token.`); + core.error("To allow this workflow to push workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' together with a GitHub App in 'safe-outputs.github-app'."); + return { + success: false, + error_type: "workflows_scope_required", + error: `${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) requiring the 'workflows' scope. The token used for the safe-outputs checkout does not have this scope. Fix: configure 'push-to-pull-request-branch.allow-workflows: true' with a GitHub App in 'safe-outputs.github-app', or exclude workflow files from the changeset.`, + }; +} + /** * Main handler factory for push_to_pull_request_branch * Returns a message handler function that processes individual push_to_pull_request_branch messages @@ -1013,11 +1045,24 @@ async function main(config = {}) { await exec.exec("git", ["checkout", "-b", reviewBranchName], baseGitOpts); core.info(`Created review branch: ${reviewBranchName}`); - // Push the review branch - await exec.exec("git", ["push", "origin", reviewBranchName], { + // Push the review branch — use getExecOutput to capture stderr so we + // can detect GitHub's "workflows scope required" rejection and surface + // a typed, actionable error instead of a bare git exit-1. + const reviewPushOutput = await exec.getExecOutput("git", ["push", "origin", reviewBranchName], { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts, + ignoreReturnCode: true, }); + if (reviewPushOutput.exitCode !== 0) { + const reviewPushStderr = (reviewPushOutput.stderr || "").trim(); + // GitHub rejects pushes to branches containing .github/workflows/** changes + // when the token lacks the 'workflows' scope. Surface this as a typed + // error so the caller can distinguish it from a generic push failure. + if (isWorkflowsScopeRejection(reviewPushStderr)) { + return buildWorkflowsScopeError("Review branch", core); + } + throw new Error(`git push origin ${reviewBranchName} failed (exit code ${reviewPushOutput.exitCode}): ${reviewPushStderr}`); + } core.info(`Pushed review branch: ${reviewBranchName}`); // Create PR from review branch to original branch @@ -1166,10 +1211,19 @@ async function main(config = {}) { core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`); try { await exec.exec("git", ["checkout", "-b", fallbackBranchName], baseGitOpts); - await exec.exec("git", ["push", "origin", fallbackBranchName], { + // Use getExecOutput to capture stderr for 'workflows' scope diagnostics + const fallbackPushOutput = await exec.getExecOutput("git", ["push", "origin", fallbackBranchName], { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts, + ignoreReturnCode: true, }); + if (fallbackPushOutput.exitCode !== 0) { + const fallbackPushStderr = (fallbackPushOutput.stderr || "").trim(); + if (isWorkflowsScopeRejection(fallbackPushStderr)) { + return buildWorkflowsScopeError("Fallback branch", core); + } + throw new Error(`git push origin ${fallbackBranchName} failed (exit code ${fallbackPushOutput.exitCode}): ${fallbackPushStderr}`); + } const fallbackBody = [ "> [!NOTE]", diff --git a/setup/js/route_slash_command.cjs b/setup/js/route_slash_command.cjs index 3113b67..61712dd 100644 --- a/setup/js/route_slash_command.cjs +++ b/setup/js/route_slash_command.cjs @@ -295,16 +295,29 @@ async function addImmediateStatusComment() { } } +/** + * @param {Array} values + * @returns {string} + */ +function firstNonEmptyString(values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value; + } + } + return ""; +} + /** * Dispatches a workflow with the API version header required by GitHub REST. * @param {string} workflowId * @param {string} ref * @param {Record} inputs - * @returns {Promise} + * @returns {Promise<{ dispatched: boolean, run_id?: number, run_url?: string }>} */ async function dispatchWorkflow(workflowId, ref, inputs) { try { - await github.rest.actions.createWorkflowDispatch({ + const dispatchArgs = { owner: context.repo.owner, repo: context.repo.repo, workflow_id: workflowId, @@ -313,17 +326,91 @@ async function dispatchWorkflow(workflowId, ref, inputs) { headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION, }, - }); - return true; + }; + + /** @type {{ data?: any }} */ + let response; + try { + response = await github.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", { + ...dispatchArgs, + return_run_details: true, + }); + } catch (dispatchError) { + /** @type {any} */ + const err = dispatchError; + const status = err && typeof err === "object" ? err.status : undefined; + const message = typeof err?.response?.data?.message === "string" ? err.response.data.message : String(dispatchError); + const isValidationStatus = status === 400 || status === 422; + const mentionsReturnRunDetails = typeof message === "string" && message.toLowerCase().includes("return_run_details"); + if (!(isValidationStatus && mentionsReturnRunDetails)) { + throw err; + } + core.info("Workflow dispatch endpoint rejected 'return_run_details' (common on older GHES versions); retrying without it."); + response = await github.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", dispatchArgs); + } + + const responseData = response?.data || {}; + // GitHub may return run metadata in either shape: + // - flat: { workflow_run_id, workflow_run_url } (workflow_run_url is a REST API URL) + // - nested: { workflow_run: { id, html_url, url } } (url is a REST API URL; html_url is the Actions page URL) + const parsedRunId = Number(responseData?.workflow_run_id ?? responseData?.workflow_run?.id); + const runId = Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined; + const serverUrl = context.serverUrl || process.env.GITHUB_SERVER_URL || "https://github.com"; + // Prefer the constructed HTML URL when runId is available — it is always the Actions run page URL. + // Avoid workflow_run_url and workflow_run.url which are REST API URLs, not Actions run page URLs. + const runUrlFromRunId = runId ? `${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}` : undefined; + const runUrlFromResponse = firstNonEmptyString([responseData?.workflow_run?.html_url]); + const runUrl = runUrlFromRunId || runUrlFromResponse; + return { + dispatched: true, + ...(runId ? { run_id: runId } : {}), + ...(runUrl ? { run_url: runUrl } : {}), + }; } catch (error) { if (isDisabledWorkflowDispatchError(error)) { core.info(`Skipping workflow '${workflowId}' because it is disabled.`); - return false; + return { dispatched: false }; } throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`); } } +/** + * Update the shared status comment with dispatched workflow run metadata. + * @param {{ status_comment_id: string, status_comment_url?: string, status_comment_repo?: string }} statusCommentContext + * @param {string} eventName + * @param {string} workflowId + * @param {string|undefined} runUrl + */ +async function updateStatusCommentWithDispatch(statusCommentContext, eventName, workflowId, runUrl) { + if (!statusCommentContext?.status_comment_id) { + return; + } + if (!runUrl) { + return; + } + try { + await createOrReuseStatusComment({ + ...context, + eventName: "workflow_dispatch", + payload: { + inputs: { + event_name: eventName, + event_payload: JSON.stringify(context.payload || {}), + aw_context: JSON.stringify({ + ...statusCommentContext, + dispatched_workflow_name: workflowId.replace(/\.lock\.yml$/, ""), + dispatched_run_url: runUrl, + }), + }, + }, + nonFatalStatusCommentErrors: true, + }); + } catch (error) { + core.warning(`Failed to update immediate status comment with dispatched run details: ${String(error)}`); + } +} + function isBuiltinHelpEnabled() { const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); if (!raw || raw === "true") { @@ -602,7 +689,7 @@ async function main() { const dispatched = await dispatchWorkflow(workflowID, ref, { aw_context: JSON.stringify(awContext), }); - if (dispatched) { + if (dispatched.dispatched) { core.info(`Dispatched '${workflowID}' for label '${labelName}'`); } } @@ -669,8 +756,11 @@ async function main() { const dispatched = await dispatchWorkflow(workflowID, ref, { aw_context: JSON.stringify(awContext), }); - if (dispatched) { + if (dispatched.dispatched) { core.info(`Dispatched '${workflowID}' for '/${commandName}'`); + if (maintainsStatusComment(route) && statusCommentContext) { + await updateStatusCommentWithDispatch(statusCommentContext, identifier, workflowID, dispatched.run_url); + } } } core.info(`Completed centralized routing for '/${commandName}'.`); diff --git a/setup/js/run_operation_update_upgrade.cjs b/setup/js/run_operation_update_upgrade.cjs index 9d8d27d..befcc0e 100644 --- a/setup/js/run_operation_update_upgrade.cjs +++ b/setup/js/run_operation_update_upgrade.cjs @@ -17,9 +17,13 @@ const KNOWN_FILES_UPDATE = [".github/aw/actions-lock.json"]; */ const KNOWN_FILES_UPGRADE = [ ".github/aw/actions-lock.json", + ".github/aw/instructions.md", ".github/skills/agentic-workflows/SKILL.md", - ".github/agents/agentic-workflows.agent.md", + ".github/skills/agentic-workflow-designer/SKILL.md", + ".github/agents/agentic-workflows.md", + ".github/agents/interactive-agent-designer.agent.md", // Old agent files that may be deleted by deleteOldAgentFiles: + ".github/agents/agentic-workflows.agent.md", ".github/agents/create-agentic-workflow.agent.md", ".github/agents/debug-agentic-workflow.agent.md", ".github/agents/create-shared-agentic-workflow.agent.md", @@ -112,7 +116,7 @@ async function main() { const knownFiles = isUpgrade ? KNOWN_FILES_UPGRADE : KNOWN_FILES_UPDATE; for (const file of knownFiles) { try { - await exec.exec("git", ["add", "--", file]); + await exec.exec("git", ["add", "-A", "--", file]); } catch (error) { core.warning(`Failed to stage '${file}': ${getErrorMessage(error)}`); } diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index d44c025..7bf35a6 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -447,7 +447,8 @@ "event": { "type": "string", "enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"], - "description": "Review decision: APPROVE to approve the pull request, REQUEST_CHANGES to formally request changes before merging, or COMMENT for general feedback without a formal decision. Defaults to COMMENT when omitted." + "description": "Review decision: APPROVE to approve the pull request, REQUEST_CHANGES to formally request changes before merging, or COMMENT for general feedback without a formal decision. Defaults to COMMENT when omitted.", + "x-synonyms": ["action"] }, "pull_request_number": { "type": ["number", "string"], diff --git a/setup/js/set_issue_type.cjs b/setup/js/set_issue_type.cjs index 631bcf7..58f0d26 100644 --- a/setup/js/set_issue_type.cjs +++ b/setup/js/set_issue_type.cjs @@ -35,16 +35,16 @@ async function getIssueNodeId(githubClient, owner, repo, issueNumber) { } /** - * Fetches the available issue types for an organization. - * For personal-account owners the query returns null and the call site receives an empty array. + * Fetches the available issue types for a repository. * @param {Object} githubClient - Authenticated GitHub client - * @param {string} owner - Organization login + * @param {string} owner - Repository owner + * @param {string} repo - Repository name * @returns {Promise>} Issue type nodes */ -async function fetchIssueTypesForOrg(githubClient, owner) { +async function fetchIssueTypesForRepo(githubClient, owner, repo) { const result = await githubClient.graphql( - `query($owner: String!) { - organization(login: $owner) { + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { issueTypes(first: 100) { nodes { id @@ -53,9 +53,9 @@ async function fetchIssueTypesForOrg(githubClient, owner) { } } }`, - { owner } + { owner, repo } ); - return result?.organization?.issueTypes?.nodes ?? []; + return result?.repository?.issueTypes?.nodes ?? []; } /** @@ -67,7 +67,7 @@ async function fetchIssueTypesForOrg(githubClient, owner) { * @returns {Promise} */ async function setIssueTypeById(githubClient, issueNodeId, issueTypeId, intentMetadata) { - const issueType = { id: issueTypeId, ...intentMetadata }; + const issueType = { issueTypeId, ...intentMetadata }; await githubClient.graphql( `mutation($issueId: ID!, $issueType: IssueTypeUpdateInput!) { updateIssue(input: { id: $issueId, issueType: $issueType }) { @@ -342,9 +342,9 @@ async function main(config = {}) { core.info(`Using GraphQL intent path (issue_intents runtime feature enabled)`); core.info(`Fetching issue node ID for issue #${issueNumber}`); const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber); - core.info(`Fetching issue types for org ${owner}`); - const issueTypes = await fetchIssueTypesForOrg(githubClient, owner); - core.info(`Found ${issueTypes.length} issue type(s) for org ${owner}`); + core.info(`Fetching issue types for repo ${owner}/${repo}`); + const issueTypes = await fetchIssueTypesForRepo(githubClient, owner, repo); + core.info(`Found ${issueTypes.length} issue type(s) for repo ${owner}/${repo}`); const typeNode = issueTypes.find(t => t.name.toLowerCase() === resolvedIssueTypeName.toLowerCase()); if (!typeNode) { const availableNames = issueTypes.map(t => t.name).join(", "); diff --git a/setup/js/start_mcp_gateway.cjs b/setup/js/start_mcp_gateway.cjs index 67d08f0..f4e1f50 100644 --- a/setup/js/start_mcp_gateway.cjs +++ b/setup/js/start_mcp_gateway.cjs @@ -65,6 +65,38 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +/** + * Builds targeted context for a JSON.parse error so logs can point at the likely key. + * + * @param {string} jsonText + * @param {string} parseErrorMessage + * @returns {{line: number, column: number, lineText: string, key: string | null} | null} + */ +function getJSONParseErrorContext(jsonText, parseErrorMessage) { + const posMatch = parseErrorMessage.match(/position\s+(\d+)/i); + if (!posMatch) { + return null; + } + + const pos = Number(posMatch[1]); + if (!Number.isFinite(pos) || pos < 0) { + return null; + } + + const safePos = Math.min(pos, Math.max(0, jsonText.length - 1)); + const before = jsonText.slice(0, safePos); + const line = before.split("\n").length; + const lineStart = before.lastIndexOf("\n") + 1; + const lineEnd = jsonText.indexOf("\n", safePos); + const resolvedLineEnd = lineEnd === -1 ? jsonText.length : lineEnd; + const lineText = jsonText.slice(lineStart, resolvedLineEnd); + const column = safePos - lineStart + 1; + const keyMatch = lineText.match(/"([^"]+)"\s*:/); + const key = keyMatch ? keyMatch[1] : null; + + return { line, column, lineText, key }; +} + /** * Normalizes GH_AW_OTLP_IF_MISSING to a supported mode. * @param {string | undefined} value @@ -367,7 +399,16 @@ async function main() { core.error("ERROR: Configuration is not valid JSON"); core.error(""); core.error("JSON validation error:"); - core.error(/** @type {Error} */ err.message); + const parseMessage = /** @type {Error} */ err.message; + core.error(parseMessage); + const parseContext = getJSONParseErrorContext(mcpConfig, parseMessage); + if (parseContext) { + core.error(`Likely offending location: line ${parseContext.line}, column ${parseContext.column}`); + if (parseContext.key) { + core.error(`Likely offending key: ${parseContext.key}`); + } + core.error(`Context line: ${parseContext.lineText}`); + } core.error(""); core.error("Configuration content:"); const lines = mcpConfig.split("\n"); @@ -909,5 +950,6 @@ module.exports = { getOTLPIfMissingMode, hasNonEmptyOTLPHeaders, isOTLPIfMissingIgnore, + getJSONParseErrorContext, resolveCopilotConfigPaths, }; diff --git a/setup/js/templatable.cjs b/setup/js/templatable.cjs index c2035e3..7dfffa7 100644 --- a/setup/js/templatable.cjs +++ b/setup/js/templatable.cjs @@ -22,6 +22,8 @@ * - boolean `false` → `false` * - string `"true"` → `true` * - string `"false"` → `false` + * - string variants that normalize to `"false"` after trim/lowercase + * (for example `" False "`) → `false` * - any other string (e.g. a resolved GitHub Actions expression value * that was not "false") → `true` * @@ -32,7 +34,7 @@ */ function parseBoolTemplatable(value, defaultValue = true) { if (value === undefined || value === null) return defaultValue; - return String(value) !== "false"; + return String(value).trim().toLowerCase() !== "false"; } /** diff --git a/setup/js/unified_timeline.cjs b/setup/js/unified_timeline.cjs index 72634fd..57129ad 100644 --- a/setup/js/unified_timeline.cjs +++ b/setup/js/unified_timeline.cjs @@ -27,8 +27,6 @@ const TMP_GH_AW = "/tmp/gh-aw"; const GATEWAY_JSONL_PATH = `${TMP_GH_AW}/mcp-logs/gateway.jsonl`; const RPC_MESSAGES_PATH = `${TMP_GH_AW}/mcp-logs/rpc-messages.jsonl`; const FIREWALL_AUDIT_PATH = `${TMP_GH_AW}/sandbox/firewall/audit/audit.jsonl`; -/** Base directory to search recursively for events.jsonl */ -const AGENT_SESSION_STATE_DIR = `${TMP_GH_AW}/sandbox/agent/logs/copilot-session-state`; // --------------------------------------------------------------------------- // Event-source and event-kind constants (mirror Go constants) @@ -461,63 +459,24 @@ function collectUnifiedTimelineEvents(opts = {}) { function buildUnifiedTimelineMarkdown(events) { if (!events || events.length === 0) return ""; - // Build summary counts - let gwCount = 0, - fwCount = 0, - agCount = 0; - let toolCalls = 0, - difcFiltered = 0, - guardBlocked = 0; - let netAllowed = 0, - netBlocked = 0; - let agentTurns = 0, - agentToolStarts = 0, - agentToolDones = 0; - - for (const evt of events) { - switch (evt.source) { - case SOURCE_GATEWAY: - gwCount++; - break; - case SOURCE_FIREWALL: - fwCount++; - break; - case SOURCE_AGENT: - agCount++; - break; - } - switch (evt.kind) { - case KIND_TOOL_CALL: - toolCalls++; - break; - case KIND_DIFC_FILTERED: - difcFiltered++; - break; - case KIND_GUARD_BLOCKED: - guardBlocked++; - break; - case KIND_NET_ALLOWED: - netAllowed++; - break; - case KIND_NET_BLOCKED: - netBlocked++; - break; - case KIND_AGENT_TURN: - agentTurns++; - break; - case KIND_AGENT_TOOL_START: - agentToolStarts++; - break; - case KIND_AGENT_TOOL_DONE: - agentToolDones++; - break; - } + // Tally events per source and per kind in a single pass. + // NOTE: byKind is a global tally. The gateway / firewall / agent stats + // lines each assume their KIND_* constants do not appear in other sources. + // If you add a new kind, ensure it is unique across all collectors. + /** @type {Record} */ + const bySource = {}; + /** @type {Record} */ + const byKind = {}; + for (const { source, kind } of events) { + bySource[source] = (bySource[source] ?? 0) + 1; + byKind[kind] = (byKind[kind] ?? 0) + 1; } - const summaryParts = [`${events.length} events`]; - if (gwCount > 0) summaryParts.push(`GW:${gwCount}`); - if (fwCount > 0) summaryParts.push(`FW:${fwCount}`); - if (agCount > 0) summaryParts.push(`AG:${agCount}`); + const gwCount = bySource[SOURCE_GATEWAY] ?? 0; + const fwCount = bySource[SOURCE_FIREWALL] ?? 0; + const agCount = bySource[SOURCE_AGENT] ?? 0; + + const summaryParts = [`${events.length} events`, ...(gwCount > 0 ? [`GW:${gwCount}`] : []), ...(fwCount > 0 ? [`FW:${fwCount}`] : []), ...(agCount > 0 ? [`AG:${agCount}`] : [])]; const lines = []; @@ -526,13 +485,13 @@ function buildUnifiedTimelineMarkdown(events) { lines.push(``); lines.push(`**Total Events:** ${events.length}`); if (gwCount > 0) { - lines.push(`**Gateway (GW):** ${gwCount} — tool_calls=${toolCalls}, difc_filtered=${difcFiltered}, guard_blocked=${guardBlocked}`); + lines.push(`**Gateway (GW):** ${gwCount} — tool_calls=${byKind[KIND_TOOL_CALL] ?? 0}, difc_filtered=${byKind[KIND_DIFC_FILTERED] ?? 0}, guard_blocked=${byKind[KIND_GUARD_BLOCKED] ?? 0}`); } if (fwCount > 0) { - lines.push(`**Firewall (FW):** ${fwCount} — allowed=${netAllowed}, blocked=${netBlocked}`); + lines.push(`**Firewall (FW):** ${fwCount} — allowed=${byKind[KIND_NET_ALLOWED] ?? 0}, blocked=${byKind[KIND_NET_BLOCKED] ?? 0}`); } if (agCount > 0) { - lines.push(`**Agent (AG):** ${agCount} — turns=${agentTurns}, tool_start=${agentToolStarts}, tool_done=${agentToolDones}`); + lines.push(`**Agent (AG):** ${agCount} — turns=${byKind[KIND_AGENT_TURN] ?? 0}, tool_start=${byKind[KIND_AGENT_TOOL_START] ?? 0}, tool_done=${byKind[KIND_AGENT_TOOL_DONE] ?? 0}`); } lines.push(``); lines.push(`| Time | Src | Kind | Detail | Status |`); diff --git a/setup/md/agent_failure_comment.md b/setup/md/agent_failure_comment.md index 46ba4fb..e6e7075 100644 --- a/setup/md/agent_failure_comment.md +++ b/setup/md/agent_failure_comment.md @@ -1,3 +1,3 @@ Agent job [{run_id}]({run_url}) failed. -{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} +{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{http_400_response_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} diff --git a/setup/md/agent_failure_issue.md b/setup/md/agent_failure_issue.md index df928f9..5cac0c2 100644 --- a/setup/md/agent_failure_issue.md +++ b/setup/md/agent_failure_issue.md @@ -4,7 +4,7 @@ **Branch:** {branch} **Run:** {run_url}{pull_request_info} -{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} +{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{http_400_response_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} ### Action Required diff --git a/setup/md/http_400_response_error.md b/setup/md/http_400_response_error.md new file mode 100644 index 0000000..fa6eae7 --- /dev/null +++ b/setup/md/http_400_response_error.md @@ -0,0 +1,13 @@ +> [!WARNING] +> **HTTP 400 Bad Request from agentic engine**: The agent failed after the engine returned `Response status code does not indicate success: 400 (Bad Request)`. + +This is usually a **request validation failure** rather than a timeout or quota issue. + +
+How to debug this + +1. Inspect the run's `agent-stdio.log` and `safeoutputs.jsonl` artifacts around the first HTTP 400. +2. Check for malformed request data, invalid tool-call payloads, or stale conversation state being replayed. +3. If the failure happened on a resumed session, rerun fresh once to confirm whether persisted state is corrupt. + +
diff --git a/setup/md/workflow_install_note.md b/setup/md/workflow_install_note.md index 41df14a..e73a9e5 100644 --- a/setup/md/workflow_install_note.md +++ b/setup/md/workflow_install_note.md @@ -1,5 +1,5 @@
-Add this agentic workflows to your repo +Add this agentic workflow to your repo To install this agentic workflow, run diff --git a/setup/post.js b/setup/post.js index 2834ca9..c20bf33 100644 --- a/setup/post.js +++ b/setup/post.js @@ -14,7 +14,7 @@ const { spawnSync } = require("child_process"); const fs = require("fs"); function isDebugModeEnabled() { - const toBool = (value) => { + const toBool = value => { const normalized = String(value || "").toLowerCase(); return normalized === "1" || normalized === "true"; }; @@ -64,9 +64,7 @@ function listTmpGhAwFiles(tmpDir, maxDepth, maxFiles) { walk(tmpDir, 0); const truncated = files.length >= maxFiles; - console.log( - `[debug] listing files under ${tmpDir} (max depth ${maxDepth}, max files ${maxFiles})`, - ); + console.log(`[debug] listing files under ${tmpDir} (max depth ${maxDepth}, max files ${maxFiles})`); if (files.length === 0) { console.log("[debug] no files found"); } else { @@ -121,4 +119,52 @@ function listTmpGhAwFiles(tmpDir, maxDepth, maxFiles) { console.error(`Warning: failed to clean up ${tmpDir}: ${err.message}`); } } + + // Clean up AWF chroot home directories under /tmp (e.g. /tmp/awf-*-chroot-home). + // These are created by AWF when running with --enable-host-access on GitHub-hosted runners. + // Files inside may be owned by root (written by Docker containers or privileged AWF processes), + // causing EACCES failures if cleanup is attempted without sudo. + const awfChrootHomeFindResult = spawnSync( + "sudo", + ["find", "/tmp", "-maxdepth", "1", "-name", "awf-*-chroot-home", "-type", "d", "-print"], + { encoding: "utf8" } + ); + if (awfChrootHomeFindResult.status !== 0) { + console.log("Failed to inspect /tmp/awf-*-chroot-home directories"); + } else { + const awfChrootHomeDirs = awfChrootHomeFindResult.stdout + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + if (awfChrootHomeDirs.length === 0) { + console.log("No /tmp/awf-*-chroot-home directories found"); + } else { + const awfChrootHomeCleanupResult = spawnSync( + "sudo", + [ + "find", + "/tmp", + "-maxdepth", + "1", + "-name", + "awf-*-chroot-home", + "-type", + "d", + "-exec", + "rm", + "-rf", + "--", + "{}", + "+" + ], + { stdio: "inherit" } + ); + if (awfChrootHomeCleanupResult.status === 0) { + const awfChrootHomeNoun = awfChrootHomeDirs.length === 1 ? "directory" : "directories"; + console.log(`Cleaned up ${awfChrootHomeDirs.length} /tmp/awf-*-chroot-home ${awfChrootHomeNoun}`); + } else { + console.log("Failed to clean /tmp/awf-*-chroot-home directories"); + } + } + } })(); diff --git a/setup/sh/install_copilot_cli.sh b/setup/sh/install_copilot_cli.sh index 8a15fd0..d3ba245 100755 --- a/setup/sh/install_copilot_cli.sh +++ b/setup/sh/install_copilot_cli.sh @@ -43,6 +43,15 @@ echo "Ensuring correct ownership of $COPILOT_DIR..." mkdir -p "$COPILOT_DIR" sudo chown -R "$(id -u):$(id -g)" "$COPILOT_DIR" +# Clean up any stale AWF chroot home directories left by previous runs. +# When AWF ran with `sudo -E awf --enable-host-access`, it created +# /tmp/awf-*-chroot-home directories with root-owned files. These cause +# EACCES failures in the Copilot CLI cleanup path (rimrafSync) on the same or +# subsequent runs, which reports as "engine terminated unexpectedly". +# Remove them here before the agent starts so the runner is in a clean state. +echo "Cleaning up stale AWF chroot home directories..." +sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null || true + # Detect OS and architecture OS="$(uname -s)" ARCH="$(uname -m)" diff --git a/setup/sh/proxy_env_lib.sh b/setup/sh/proxy_env_lib.sh new file mode 100755 index 0000000..6e1f6d7 --- /dev/null +++ b/setup/sh/proxy_env_lib.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Shared environment utilities sourced by start_difc_proxy.sh and start_cli_proxy.sh. +# Do not invoke this file directly — source it from the proxy startup scripts. + +# normalize_github_host strips a URL down to its bare hostname, removing the +# protocol prefix, any trailing slashes, any path components, and any port +# number. The port is stripped because GH_HOST is a hostname-only value that +# the gh CLI does not expect to include a port; upstream API URLs that need the +# port are constructed from GITHUB_SERVER_URL (which preserves the port). +# +# Examples: +# https://github.com/ → github.com +# https://myorg.ghe.com → myorg.ghe.com +# https://myghes.corp:8443 → myghes.corp +# https://myghes.corp:8443/ → myghes.corp +normalize_github_host() { + local host="$1" + + host="${host%/}" + if [[ "$host" =~ ^https?:// ]]; then + host="${host#http://}" + host="${host#https://}" + host="${host%%/*}" + fi + + # Strip port number (e.g. myghes.corp:8443 → myghes.corp). + # The regex matches "one or more non-[ chars, then :digits at end-of-string", + # which catches host:port notation while skipping IPv6 bracket notation ([::1]). + if [[ "$host" =~ ^[^\[]+:[0-9]+$ ]]; then + host="${host%:*}" + fi + + echo "$host" +} + +# derive_proxy_upstream_env normalises the upstream GitHub host and exports the +# environment variables that the proxy container needs for correct routing. +# +# Design notes: +# +# GH_HOST is always set unconditionally to the value derived from +# GITHUB_SERVER_URL. On GitHub-hosted runners the workflow-level environment +# can have GH_HOST=github.com even when the server URL points at a *.ghe.com +# tenant, which would cause the proxy to route to the wrong upstream. +# Unconditional assignment (no :-) is intentional so that any stale +# github.com default is always corrected. +# +# GITHUB_HOST and GITHUB_ENTERPRISE_HOST use ${:-} fallback because they are +# supplementary aliases; if the caller has already set them to the correct +# tenant hostname, preserving that value is safe. +# +# GITHUB_COPILOT_BASE_URL is derived automatically for *.ghe.com tenants only. +# On GHES (on-premises) installations the Copilot API endpoint is not +# predictable from the server URL alone, so no automatic derivation is +# attempted. Callers that need a non-default Copilot URL must set +# GITHUB_COPILOT_BASE_URL explicitly before invoking this function. +derive_proxy_upstream_env() { + local server_url="${GITHUB_SERVER_URL:-https://github.com}" + local server_host + local github_host="${GH_HOST:-${GITHUB_HOST:-${GITHUB_ENTERPRISE_HOST:-}}}" + + server_url="${server_url%/}" + server_host="$(normalize_github_host "$server_url")" + # Unconditionally normalise to the server host when the current value is + # absent or is a stale github.com default on a non-github.com server. + if [ -z "$github_host" ] || { [ "$server_host" != "github.com" ] && [ "$github_host" = "github.com" ]; }; then + github_host="$server_host" + fi + if [ -z "$github_host" ]; then + github_host="github.com" + fi + + # Always export the normalised host so any stale default is overridden. + export GH_HOST="$github_host" + + if [ "$github_host" != "github.com" ]; then + export GITHUB_HOST="${GITHUB_HOST:-$github_host}" + export GITHUB_ENTERPRISE_HOST="${GITHUB_ENTERPRISE_HOST:-$github_host}" + fi + + if [ -z "${GITHUB_API_URL:-}" ] || { [ "$github_host" != "github.com" ] && [ "${GITHUB_API_URL}" = "https://api.github.com" ]; }; then + if [ "$github_host" = "github.com" ]; then + export GITHUB_API_URL="https://api.github.com" + elif [[ "$github_host" == *.ghe.com ]]; then + export GITHUB_API_URL="https://api.${github_host}" + else + export GITHUB_API_URL="${server_url}/api/v3" + fi + fi + + if [ -z "${GITHUB_GRAPHQL_URL:-}" ] || { [ "$github_host" != "github.com" ] && [ "${GITHUB_GRAPHQL_URL}" = "https://api.github.com/graphql" ]; }; then + if [ "$github_host" = "github.com" ]; then + export GITHUB_GRAPHQL_URL="https://api.github.com/graphql" + elif [[ "$github_host" == *.ghe.com ]]; then + export GITHUB_GRAPHQL_URL="https://api.${github_host}/graphql" + else + export GITHUB_GRAPHQL_URL="${server_url}/api/graphql" + fi + fi + + # Auto-derive the Copilot API URL for *.ghe.com data-residency tenants only. + # For GHES (on-premises), the endpoint is not predictable; callers must set + # GITHUB_COPILOT_BASE_URL explicitly if they need it. + if [ -z "${GITHUB_COPILOT_BASE_URL:-}" ] && [[ "$github_host" == *.ghe.com ]]; then + export GITHUB_COPILOT_BASE_URL="https://copilot-api.${github_host}" + fi +} diff --git a/setup/sh/proxy_env_lib_test.sh b/setup/sh/proxy_env_lib_test.sh new file mode 100755 index 0000000..47cb17f --- /dev/null +++ b/setup/sh/proxy_env_lib_test.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set +o histexpand + +# Tests for proxy_env_lib.sh — covers normalize_github_host and +# derive_proxy_upstream_env across github.com, *.ghe.com, and GHES cases. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="${SCRIPT_DIR}/proxy_env_lib.sh" + +# Source the library so we can call its functions directly. +# shellcheck source=proxy_env_lib.sh +source "$LIB" + +PASS=0 +FAIL=0 + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $label" + PASS=$((PASS + 1)) + else + echo "FAIL: $label" + echo " expected: $expected" + echo " actual: $actual" + FAIL=$((FAIL + 1)) + fi +} + +echo "Testing proxy_env_lib.sh" +echo "========================" + +echo "" +echo "normalize_github_host" +echo "---------------------" + +assert_eq "plain github.com" \ + "github.com" "$(normalize_github_host "github.com")" + +assert_eq "https://github.com" \ + "github.com" "$(normalize_github_host "https://github.com")" + +assert_eq "https://github.com/" \ + "github.com" "$(normalize_github_host "https://github.com/")" + +assert_eq "*.ghe.com tenant" \ + "myorg.ghe.com" "$(normalize_github_host "https://myorg.ghe.com")" + +assert_eq "GHES no port" \ + "myghes.corp" "$(normalize_github_host "https://myghes.corp")" + +assert_eq "GHES with port" \ + "myghes.corp" "$(normalize_github_host "https://myghes.corp:8443")" + +assert_eq "GHES with port and trailing slash" \ + "myghes.corp" "$(normalize_github_host "https://myghes.corp:8443/")" + +assert_eq "GHES with port and path" \ + "myghes.corp" "$(normalize_github_host "https://myghes.corp:8443/some/path")" + +assert_eq "http scheme" \ + "myghes.corp" "$(normalize_github_host "http://myghes.corp:8080")" + +echo "" +echo "derive_proxy_upstream_env — public github.com" +echo "---------------------------------------------" + +unset GH_HOST GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL GITHUB_COPILOT_BASE_URL +export GITHUB_SERVER_URL="https://github.com" +derive_proxy_upstream_env +assert_eq "github.com: GH_HOST" "github.com" "$GH_HOST" +assert_eq "github.com: GITHUB_API_URL" "https://api.github.com" "$GITHUB_API_URL" +assert_eq "github.com: GITHUB_GRAPHQL_URL" "https://api.github.com/graphql" "$GITHUB_GRAPHQL_URL" +assert_eq "github.com: GITHUB_COPILOT_BASE_URL empty" "" "${GITHUB_COPILOT_BASE_URL:-}" + +echo "" +echo "derive_proxy_upstream_env — *.ghe.com tenant" +echo "---------------------------------------------" + +unset GH_HOST GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL GITHUB_COPILOT_BASE_URL +export GITHUB_SERVER_URL="https://myorg.ghe.com" +derive_proxy_upstream_env +assert_eq "ghe.com: GH_HOST" "myorg.ghe.com" "$GH_HOST" +assert_eq "ghe.com: GITHUB_API_URL" "https://api.myorg.ghe.com" "$GITHUB_API_URL" +assert_eq "ghe.com: GITHUB_GRAPHQL_URL" "https://api.myorg.ghe.com/graphql" "$GITHUB_GRAPHQL_URL" +assert_eq "ghe.com: GITHUB_COPILOT_BASE_URL" "https://copilot-api.myorg.ghe.com" "$GITHUB_COPILOT_BASE_URL" + +echo "" +echo "derive_proxy_upstream_env — stale GH_HOST=github.com on ghe.com tenant" +echo "------------------------------------------------------------------------" + +unset GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL GITHUB_COPILOT_BASE_URL +GH_HOST="github.com" +export GITHUB_SERVER_URL="https://myorg.ghe.com" +derive_proxy_upstream_env +assert_eq "stale GH_HOST overridden" "myorg.ghe.com" "$GH_HOST" +assert_eq "stale: GITHUB_API_URL derived from tenant" "https://api.myorg.ghe.com" "$GITHUB_API_URL" + +echo "" +echo "derive_proxy_upstream_env — explicit correct GH_HOST preserved" +echo "---------------------------------------------------------------" + +unset GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL GITHUB_COPILOT_BASE_URL +GH_HOST="myorg.ghe.com" +export GITHUB_SERVER_URL="https://myorg.ghe.com" +derive_proxy_upstream_env +assert_eq "correct GH_HOST kept" "myorg.ghe.com" "$GH_HOST" + +echo "" +echo "derive_proxy_upstream_env — GHES with non-standard port" +echo "--------------------------------------------------------" + +unset GH_HOST GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL GITHUB_COPILOT_BASE_URL +export GITHUB_SERVER_URL="https://myghes.corp:8443" +derive_proxy_upstream_env +assert_eq "GHES port: GH_HOST no port" "myghes.corp" "$GH_HOST" +assert_eq "GHES port: GITHUB_API_URL with port" "https://myghes.corp:8443/api/v3" "$GITHUB_API_URL" +assert_eq "GHES port: GITHUB_GRAPHQL_URL with port" "https://myghes.corp:8443/api/graphql" "$GITHUB_GRAPHQL_URL" +assert_eq "GHES port: GITHUB_COPILOT_BASE_URL empty" "" "${GITHUB_COPILOT_BASE_URL:-}" + +echo "" +echo "derive_proxy_upstream_env — explicit GITHUB_COPILOT_BASE_URL preserved" +echo "------------------------------------------------------------------------" + +unset GH_HOST GITHUB_HOST GITHUB_ENTERPRISE_HOST GITHUB_API_URL GITHUB_GRAPHQL_URL +export GITHUB_SERVER_URL="https://myorg.ghe.com" +export GITHUB_COPILOT_BASE_URL="https://custom-copilot.example.com" +derive_proxy_upstream_env +assert_eq "explicit GITHUB_COPILOT_BASE_URL not overridden" \ + "https://custom-copilot.example.com" "$GITHUB_COPILOT_BASE_URL" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/setup/sh/start_cli_proxy.sh b/setup/sh/start_cli_proxy.sh index 409ed86..640cd2d 100644 --- a/setup/sh/start_cli_proxy.sh +++ b/setup/sh/start_cli_proxy.sh @@ -5,8 +5,9 @@ set +o histexpand # This script starts the awmg proxy container so AWF's cli-proxy container # can connect to it via host.docker.internal:18443 for gh CLI access. # -# Unlike start_difc_proxy.sh (which is for pre-agent steps), this proxy -# runs alongside AWF and does NOT modify GH_HOST or GITHUB_ENV. +# This script exports GH_HOST (and related vars) within the script for use when +# launching the proxy container, but does NOT write to $GITHUB_ENV and the +# exports do not persist beyond this script. # # Environment: # CLI_PROXY_POLICY - JSON guard policy string @@ -16,6 +17,10 @@ set +o histexpand set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=proxy_env_lib.sh +source "${SCRIPT_DIR}/proxy_env_lib.sh" + POLICY="${CLI_PROXY_POLICY:-}" CONTAINER_IMAGE="${CLI_PROXY_IMAGE:-}" @@ -29,10 +34,13 @@ MCP_LOG_DIR=/tmp/gh-aw/mcp-logs mkdir -p "$TLS_DIR" "$MCP_LOG_DIR" +derive_proxy_upstream_env + # Remove any leftover container from a prior run (e.g., cancelled job on a self-hosted runner) docker rm -f awmg-cli-proxy 2>/dev/null || true echo "Starting CLI proxy container: $CONTAINER_IMAGE" +echo "Using CLI proxy upstream host: ${GH_HOST} (API: ${GITHUB_API_URL})" # Build docker run command arguments POLICY_ARGS=() @@ -48,7 +56,13 @@ fi docker run -d --name awmg-cli-proxy "${DOCKER_NETWORK_ARGS[@]}" \ --user "$(id -u):$(id -g)" \ -e GH_TOKEN \ + -e GH_HOST \ + -e GITHUB_HOST \ + -e GITHUB_ENTERPRISE_HOST \ -e GITHUB_SERVER_URL \ + -e GITHUB_API_URL \ + -e GITHUB_GRAPHQL_URL \ + -e GITHUB_COPILOT_BASE_URL \ -e DEBUG='*' \ -v "$TLS_DIR:$TLS_DIR" \ -v "$MCP_LOG_DIR:$MCP_LOG_DIR" \ diff --git a/setup/sh/start_difc_proxy.sh b/setup/sh/start_difc_proxy.sh index 680abb0..8c6b7c5 100644 --- a/setup/sh/start_difc_proxy.sh +++ b/setup/sh/start_difc_proxy.sh @@ -19,6 +19,10 @@ set +o histexpand set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=proxy_env_lib.sh +source "${SCRIPT_DIR}/proxy_env_lib.sh" + POLICY="${DIFC_PROXY_POLICY:-}" CONTAINER_IMAGE="${DIFC_PROXY_IMAGE:-}" @@ -37,7 +41,10 @@ MCP_LOG_DIR=/tmp/gh-aw/mcp-logs mkdir -p "$PROXY_LOG_DIR" "$MCP_LOG_DIR" +derive_proxy_upstream_env + echo "Starting DIFC proxy container: $CONTAINER_IMAGE" +echo "Using DIFC proxy upstream host: ${GH_HOST} (API: ${GITHUB_API_URL})" # Remove any existing container to avoid name conflicts on cancelled/retried jobs. docker rm -f awmg-proxy 2>/dev/null || true @@ -50,7 +57,13 @@ fi docker run -d --name awmg-proxy "${DOCKER_NETWORK_ARGS[@]}" \ --user "$(id -u):$(id -g)" \ -e GH_TOKEN \ + -e GH_HOST \ + -e GITHUB_HOST \ + -e GITHUB_ENTERPRISE_HOST \ -e GITHUB_SERVER_URL \ + -e GITHUB_API_URL \ + -e GITHUB_GRAPHQL_URL \ + -e GITHUB_COPILOT_BASE_URL \ -e DEBUG='*' \ -v "$PROXY_LOG_DIR:$PROXY_LOG_DIR" \ -v "$MCP_LOG_DIR:$MCP_LOG_DIR" \