From 84eb844755a7aea57761e48aeb35f1e130d9ee58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 00:25:43 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.75.1 --- setup/js/action_input_utils.cjs | 4 + setup/js/awf_reflect.cjs | 43 ++- setup/js/awf_reflect_summary.cjs | 173 ++++++++++- setup/js/check_membership.cjs | 91 +++--- setup/js/check_workflow_recompile_needed.cjs | 275 +++++++++++++++++- setup/js/create_check_run.cjs | 196 +++++++++++++ setup/js/create_pull_request.cjs | 76 ++++- setup/js/generate_git_patch.cjs | 17 +- setup/js/generate_safe_outputs_tools.cjs | 1 + setup/js/get_base_branch.cjs | 32 +- setup/js/handle_agent_failure.cjs | 218 ++++++++++++++ setup/js/manifest_file_helpers.cjs | 13 +- setup/js/mcp_server_core.cjs | 28 ++ setup/js/messages_core.cjs | 32 +- setup/js/model_multipliers.json | 32 +- setup/js/pi_provider.cjs | 133 ++++++++- setup/js/push_signed_commits.cjs | 9 +- setup/js/safe_output_handler_manager.cjs | 2 + setup/js/safe_outputs_handlers.cjs | 12 +- setup/js/safe_outputs_tools.json | 38 ++- setup/js/send_otlp_span.cjs | 58 +++- ...ifest_protection_request_changes_review.md | 5 + .../md/manifest_protection_request_review.md | 5 + .../threat_warning_request_changes_review.md | 6 + 24 files changed, 1374 insertions(+), 125 deletions(-) create mode 100644 setup/js/create_check_run.cjs create mode 100644 setup/md/manifest_protection_request_changes_review.md create mode 100644 setup/md/manifest_protection_request_review.md create mode 100644 setup/md/threat_warning_request_changes_review.md diff --git a/setup/js/action_input_utils.cjs b/setup/js/action_input_utils.cjs index 5db08e77..b5eec105 100644 --- a/setup/js/action_input_utils.cjs +++ b/setup/js/action_input_utils.cjs @@ -9,6 +9,10 @@ * some runner versions preserve the original hyphen from the input name. Checking * both forms ensures the value is resolved regardless of the runner version. * + * The underscore form has precedence: if `INPUT_` exists (even as whitespace), + * it is used. The hyphen form is only checked if the underscore form is absent or + * empty string. + * * @param {string} name - Input name in UPPER_UNDERSCORE form (e.g. "JOB_NAME") * @returns {string} Trimmed input value, or "" if not set. */ diff --git a/setup/js/awf_reflect.cjs b/setup/js/awf_reflect.cjs index 2a0e0273..c76e456c 100644 --- a/setup/js/awf_reflect.cjs +++ b/setup/js/awf_reflect.cjs @@ -26,7 +26,7 @@ const AWF_API_PROXY_REFLECT_URL = "http://api-proxy:10000/reflect"; // co-located with other AWF firewall observability data so it is included in the agent artifact. const AWF_REFLECT_OUTPUT_PATH = "/tmp/gh-aw/sandbox/firewall/awf-reflect.json"; // Milliseconds to wait for the /reflect endpoint before giving up. -const AWF_REFLECT_TIMEOUT_MS = 5000; +const AWF_REFLECT_TIMEOUT_MS = 60000; // Milliseconds to wait for each models_url fallback fetch (shorter than the main reflect timeout). const AWF_MODELS_URL_TIMEOUT_MS = 3000; // Gemini model name prefix stripped from model IDs in the Gemini models API response. @@ -165,7 +165,15 @@ async function enrichReflectModels(reflectData, timeoutMs, logger) { * logger?: (msg: string) => void, * writeFileSync?: (path: string, data: string, options: object) => void, * }=} options - * @returns {Promise} + * @returns {Promise<{ + * ok: boolean, + * reflectUrl: string, + * outputPath: string, + * bytesWritten?: number, + * reason?: "unexpected_status"|"timeout"|"request_failed", + * status?: number, + * error?: string, + * }>} */ async function fetchAWFReflect(options) { const reflectUrl = (options && options.reflectUrl) || AWF_API_PROXY_REFLECT_URL; @@ -178,7 +186,9 @@ async function fetchAWFReflect(options) { logger(`awf-reflect: fetching ${reflectUrl} (timeout=${timeoutMs}ms)`); const ac = new AbortController(); + let timedOut = false; const timer = setTimeout(() => { + timedOut = true; logger(`awf-reflect: request timed out after ${timeoutMs}ms`); ac.abort(); }, timeoutMs); @@ -187,7 +197,13 @@ async function fetchAWFReflect(options) { const res = await fetch(reflectUrl, { signal: ac.signal }); if (!res.ok) { logger(`awf-reflect: unexpected status ${res.status}, skipping`); - return; + return { + ok: false, + reflectUrl, + outputPath, + reason: "unexpected_status", + status: res.status, + }; } const reflectData = await res.json(); // Attempt to fill in null models for configured providers by fetching directly @@ -198,12 +214,31 @@ async function fetchAWFReflect(options) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); writeFile(outputPath, enrichedBody, { encoding: "utf8" }); logger(`awf-reflect: saved ${enrichedBody.length}B to ${outputPath}`); + return { + ok: true, + reflectUrl, + outputPath, + bytesWritten: enrichedBody.length, + }; } catch (err) { const e = /** @type {Error} */ err; if (e.name === "AbortError") { - return; // already logged above + return { + ok: false, + reflectUrl, + outputPath, + reason: "timeout", + error: timedOut ? `request timed out after ${timeoutMs}ms` : e.message, + }; } logger(`awf-reflect: request failed: ${e.message}`); + return { + ok: false, + reflectUrl, + outputPath, + reason: "request_failed", + error: e.message, + }; } finally { clearTimeout(timer); } diff --git a/setup/js/awf_reflect_summary.cjs b/setup/js/awf_reflect_summary.cjs index 0f6736bb..37af29e1 100644 --- a/setup/js/awf_reflect_summary.cjs +++ b/setup/js/awf_reflect_summary.cjs @@ -3,7 +3,9 @@ const fs = require("fs"); +const AWF_CONFIG_PATH = "/tmp/gh-aw/awf-config.json"; const AWF_REFLECT_PATH = "/tmp/gh-aw/sandbox/firewall/awf-reflect.json"; +const AWF_MODELS_PATH = "/tmp/gh-aw/sandbox/firewall/models.json"; /** * Read the AWF reflect payload that was persisted to disk by copilot_harness.cjs. @@ -21,6 +23,40 @@ function readReflectData() { } } +/** + * Read the AWF config payload when available. + * Returns null when the file is absent or unparseable. + * + * @returns {object|null} + */ +function readAWFConfigData() { + if (!fs.existsSync(AWF_CONFIG_PATH)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(AWF_CONFIG_PATH, "utf8")); + } catch { + return null; + } +} + +/** + * Read the sandbox.firewall models.json payload when available. + * Returns null when the file is absent or unparseable. + * + * @returns {object|null} + */ +function readRuntimeModelsData() { + if (!fs.existsSync(AWF_MODELS_PATH)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(AWF_MODELS_PATH, "utf8")); + } catch { + return null; + } +} + /** * Format a list of model IDs into a compact comma-separated string, capping the output * at `maxModels` entries and appending "… +N more" when the list is longer. @@ -40,6 +76,104 @@ function formatModelList(models, maxModels) { return `${shown.join(", ")} … +${remaining} more`; } +/** + * Normalize runtime model entries to a common table-friendly shape. + * + * Supported payload shapes: + * - { endpoints: [...] } + * - { providers: { [providerName]: { ... } } } + * - { provider: "x", models: [...] } + * + * @param {any} runtimeModelsData + * @returns {Array<{provider: string, endpoint: string, models: string[]}>} + */ +function normalizeRuntimeModelRows(runtimeModelsData) { + if (!runtimeModelsData || typeof runtimeModelsData !== "object") { + return []; + } + + /** @type {Array<{provider: string, endpoint: string, models: string[]}>} */ + const rows = []; + + /** + * @param {string} provider + * @param {any} entry + */ + function pushRow(provider, entry) { + const modelIds = extractRuntimeModelIds(entry?.models || entry?.available_models || entry?.detected_models || entry?.model_ids || entry?.availableModels); + rows.push({ + provider: String(provider || entry?.provider || entry?.name || "unknown"), + endpoint: String(entry?.endpoint || entry?.base_url || entry?.baseUrl || entry?.url || entry?.models_url || entry?.modelsUrl || "—"), + models: modelIds, + }); + } + + if (Array.isArray(runtimeModelsData.endpoints)) { + for (const entry of runtimeModelsData.endpoints) { + pushRow(entry?.provider, entry); + } + } + + if (runtimeModelsData.providers && typeof runtimeModelsData.providers === "object") { + for (const [provider, entry] of Object.entries(runtimeModelsData.providers)) { + pushRow(provider, entry); + } + } + + if (typeof runtimeModelsData.provider === "string" && Array.isArray(runtimeModelsData.models)) { + pushRow(runtimeModelsData.provider, runtimeModelsData); + } + + return rows.sort((a, b) => a.provider.localeCompare(b.provider) || a.endpoint.localeCompare(b.endpoint)); +} + +/** + * Extract model IDs from runtime models.json payload entries. + * + * @param {any} models + * @returns {string[]} + */ +function extractRuntimeModelIds(models) { + if (!Array.isArray(models)) { + return []; + } + + return models + .map(model => { + if (typeof model === "string") return model; + if (!model || typeof model !== "object") return null; + return model.id || model.name || model.model || null; + }) + .filter(Boolean) + .sort(); +} + +/** + * Normalize model aliases from awf-config.json into a table-friendly shape. + * + * @param {any} awfConfigData + * @returns {Array<{alias: string, label: string, targets: string[]}>} + */ +function normalizeModelAliasRows(awfConfigData) { + const aliasMap = awfConfigData?.apiProxy?.models; + if (!aliasMap || typeof aliasMap !== "object" || Array.isArray(aliasMap)) { + return []; + } + + return Object.entries(aliasMap) + .filter(([, targets]) => Array.isArray(targets)) + .map(([alias, targets]) => ({ + alias, + label: alias === "" ? "(default)" : alias, + targets: targets.map(target => String(target)), + })) + .sort((a, b) => { + if (a.alias === "") return -1; + if (b.alias === "") return 1; + return a.alias.localeCompare(b.alias); + }); +} + /** * Build a markdown step summary from AWF /reflect response data. * @@ -51,13 +185,15 @@ function formatModelList(models, maxModels) { * - Available models (first `maxModels` entries, with overflow indicator) * * @param {object} reflectData - Parsed /reflect JSON response - * @param {{ maxModels?: number }} options + * @param {{ maxModels?: number, runtimeModelsData?: object, awfConfigData?: object }} options * @returns {string} */ function buildReflectSummary(reflectData, options) { const maxModels = options && options.maxModels != null ? options.maxModels : 5; const endpoints = Array.isArray(reflectData.endpoints) ? reflectData.endpoints : []; const fetchComplete = reflectData.models_fetch_complete === true; + const runtimeModelRows = normalizeRuntimeModelRows(options && options.runtimeModelsData); + const modelAliasRows = normalizeModelAliasRows(options && options.awfConfigData); const lines = []; lines.push("
"); @@ -70,6 +206,8 @@ function buildReflectSummary(reflectData, options) { lines.push("No endpoint information available."); } else { const fetchNote = fetchComplete ? "" : " *(model list may be incomplete — fetch in progress)*"; + lines.push("Configured endpoints"); + lines.push(""); lines.push(`| Provider | Port | Configured | Available models${fetchNote} |`); lines.push("|----------|------|:----------:|-----------------|"); @@ -82,6 +220,28 @@ function buildReflectSummary(reflectData, options) { } } + if (runtimeModelRows.length > 0) { + lines.push(""); + lines.push("Runtime models.json"); + lines.push(""); + lines.push("| Provider | Endpoint | Available models |"); + lines.push("|----------|----------|------------------|"); + for (const row of runtimeModelRows) { + lines.push(`| ${row.provider} | ${row.endpoint} | ${formatModelList(row.models, maxModels)} |`); + } + } + + if (modelAliasRows.length > 0) { + lines.push(""); + lines.push("Model aliases"); + lines.push(""); + lines.push("| Alias | Resolution order |"); + lines.push("|-------|------------------|"); + for (const row of modelAliasRows) { + lines.push(`| ${row.label} | ${formatModelList(row.targets, maxModels)} |`); + } + } + lines.push(""); lines.push("
"); lines.push(""); @@ -90,24 +250,33 @@ function buildReflectSummary(reflectData, options) { } async function main() { + const awfConfigData = readAWFConfigData(); const reflectData = readReflectData(); + const runtimeModelsData = readRuntimeModelsData(); if (!reflectData) { core.info("AWF reflect data not available (AWF not enabled or /reflect not reachable), skipping summary"); return; } - const markdown = buildReflectSummary(reflectData, {}); + const markdown = buildReflectSummary(reflectData, { awfConfigData, runtimeModelsData }); await core.summary.addRaw(markdown).write(); core.info("AWF reflect summary written to step summary"); } if (typeof module !== "undefined" && module.exports) { module.exports = { + AWF_CONFIG_PATH, + AWF_MODELS_PATH, AWF_REFLECT_PATH, buildReflectSummary, + extractRuntimeModelIds, formatModelList, main, + normalizeModelAliasRows, + readAWFConfigData, readReflectData, + readRuntimeModelsData, + normalizeRuntimeModelRows, }; } diff --git a/setup/js/check_membership.cjs b/setup/js/check_membership.cjs index d5c159d0..4b71b5d1 100644 --- a/setup/js/check_membership.cjs +++ b/setup/js/check_membership.cjs @@ -4,6 +4,52 @@ const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot, isConfusedDeputyAttack } = require("./check_permissions_utils.cjs"); const { writeDenialSummary } = require("./pre_activation_summary.cjs"); +/** + * Attempt to authorize the actor via the bots allowlist. + * + * Returns `{ handled: true }` when a final authorization decision was reached (either + * `authorized_bot` or `bot_not_active`) and outputs/summary have already been set. + * Returns `{ handled: false }` when the actor is not in the allowlist, when the + * allowlist is empty, or when the bot-status check failed entirely — in all of these + * cases the caller should fall through to the standard repository roles check. + * + * @param {string} actorToValidate + * @param {string[]} allowedBots + * @param {string} owner + * @param {string} repo + * @returns {Promise<{ handled: boolean }>} + */ +async function checkBotAllowlistAuthorization(actorToValidate, allowedBots, owner, repo) { + if (allowedBots.length === 0 || !isAllowedBot(actorToValidate, allowedBots)) { + return { handled: false }; + } + + core.info(`Actor '${actorToValidate}' matched the allowed bots list: ${allowedBots.join(", ")}`); + + // Verify the bot is active/installed on the repository + const botStatus = await checkBotStatus(actorToValidate, owner, repo); + + if (botStatus.isBot && botStatus.isActive) { + core.info(`✅ Bot '${actorToValidate}' is active on the repository and authorized`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "authorized_bot"); + core.setOutput("user_permission", "bot"); + return { handled: true }; + } else if (botStatus.isBot && !botStatus.isActive) { + const errorMessage = `Access denied: Bot '${actorToValidate}' is not active/installed on this repository`; + core.warning(`Bot '${actorToValidate}' is in the allowed list but not active/installed on ${owner}/${repo}`); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "bot_not_active"); + core.setOutput("user_permission", "bot"); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, "The bot is in the allowed list but is not installed or active on this repository. Install the GitHub App and try again."); + return { handled: true }; + } else { + core.info(`Actor '${actorToValidate}' is in allowed bots list but bot status check failed`); + return { handled: false }; + } +} + function readWorkflowDispatchAwContext(payload) { try { const rawAwContext = payload?.inputs?.aw_context; @@ -155,6 +201,16 @@ async function main() { return; } + // If the actor is in the bots allowlist, skip the roles check entirely and go straight + // to bot-status verification. A bot listed in on.bots: is an explicit grant; the roles + // mismatch (bots typically have "none" repo permission) is expected and not actionable. + // Checking bots first also avoids a spurious "permission does not meet requirements" + // warning that would otherwise be emitted by the roles check before authorization succeeds. + const botResult = await checkBotAllowlistAuthorization(actorToValidate, allowedBots, owner, repo); + if (botResult.handled) { + return; + } + // Check if the actor has the required repository permissions const result = await checkRepositoryPermission(actorToValidate, owner, repo, requiredPermissions); @@ -163,39 +219,6 @@ async function main() { core.setOutput("result", "authorized"); core.setOutput("user_permission", result.permission); } else { - // User doesn't have required permissions (or the permission check failed with an error). - // Always attempt the bot allowlist fallback before giving up, so that GitHub Apps whose - // actor is not a recognized GitHub user (e.g. "Copilot") are not silently denied. - if (allowedBots.length > 0) { - core.info(`Checking if actor '${actorToValidate}' is in allowed bots list: ${allowedBots.join(", ")}`); - - if (isAllowedBot(actorToValidate, allowedBots)) { - core.info(`Actor '${actorToValidate}' is in the allowed bots list`); - - // Verify the bot is active/installed on the repository - const botStatus = await checkBotStatus(actorToValidate, owner, repo); - - if (botStatus.isBot && botStatus.isActive) { - core.info(`✅ Bot '${actorToValidate}' is active on the repository and authorized`); - core.setOutput("is_team_member", "true"); - core.setOutput("result", "authorized_bot"); - core.setOutput("user_permission", "bot"); - return; - } else if (botStatus.isBot && !botStatus.isActive) { - const errorMessage = `Access denied: Bot '${actorToValidate}' is not active/installed on this repository`; - core.warning(`Bot '${actorToValidate}' is in the allowed list but not active/installed on ${owner}/${repo}`); - core.setOutput("is_team_member", "false"); - core.setOutput("result", "bot_not_active"); - core.setOutput("user_permission", result.permission ?? "bot"); - core.setOutput("error_message", errorMessage); - await writeDenialSummary(errorMessage, "The bot is in the allowed list but is not installed or active on this repository. Install the GitHub App and try again."); - return; - } else { - core.info(`Actor '${actorToValidate}' is in allowed bots list but bot status check failed`); - } - } - } - // Not authorized by role or bot if (result.error) { const errorMessage = `Repository permission check failed: ${result.error}`; @@ -216,4 +239,4 @@ async function main() { } } -module.exports = { main }; +module.exports = { main, checkBotAllowlistAuthorization }; diff --git a/setup/js/check_workflow_recompile_needed.cjs b/setup/js/check_workflow_recompile_needed.cjs index c1860b26..1e6a07e7 100644 --- a/setup/js/check_workflow_recompile_needed.cjs +++ b/setup/js/check_workflow_recompile_needed.cjs @@ -2,12 +2,254 @@ /// const { getErrorMessage } = require("./error_helpers.cjs"); -const { generateFooterWithMessages, getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs"); +const { getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs"); const fs = require("fs"); +const { getGitAuthEnv } = require("./git_helpers.cjs"); +const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); +const { pushSignedCommits } = require("./push_signed_commits.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); +const RECOMPILE_ISSUE_TITLE = "[aw] agentic workflows out of sync"; +const RECOMPILE_PR_TITLE = "[aw] recompile agentic workflows"; +const RECOMPILE_PR_BRANCH = "aw/recompile-workflows"; + +function shouldCreatePullRequest() { + return getRecompileToken() !== ""; +} + +async function getEffectiveBaseBranch(owner, repo) { + const { effectiveBaseBranch } = await resolvePullRequestRepo(github, owner, repo, undefined); + return effectiveBaseBranch || "main"; +} + +function getRecompileToken() { + return process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN || ""; +} + +function logConfiguration(createPullRequest) { + core.info(`Workflow recompile mode: ${createPullRequest ? "pull-request" : "issue"}`); + core.info(`Configured maintenance token present: ${getRecompileToken() !== ""}`); +} + +function requireRecompileToken() { + const token = getRecompileToken(); + if (!token) { + throw new Error("Missing configured maintenance GitHub token secret for maintenance compile pull request creation"); + } + return token; +} + +function buildRecompilePullRequestBody(changedFiles, repository, runUrl, linkedIssueNumber) { + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Agentic Maintenance"; + const footer = getFooterWorkflowRecompileMessage({ workflowName, runUrl, repository }); + const xmlMarker = generateXMLMarker(workflowName, runUrl); + const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); + const cautionPrefix = detectionCaution ? `${detectionCaution}\n\n` : ""; + const linkedIssueLine = linkedIssueNumber ? `Fixes #${linkedIssueNumber}\n\n` : ""; + const fileList = changedFiles.map(file => `- \`${file}\``).join("\n"); + + return `${cautionPrefix}## Workflow Recompilation + +This automated maintenance run detected generated workflow changes and prepared this pull request to update the lock files. + +${linkedIssueLine}## Changed Files + +${fileList} + +--- +${footer} + +${xmlMarker} +`; +} + +async function getChangedLockFiles() { + // Compare the current working tree against HEAD to capture the lock files + // changed by this maintenance compile run before any branch operations. + const { stdout } = await exec.getExecOutput("git", ["diff", "--name-only", ".github/workflows/*.lock.yml"], { + ignoreReturnCode: true, + }); + return stdout + .split("\n") + .map(file => file.trim()) + .filter(Boolean); +} + +async function getLocalHeadSha() { + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + return stdout.trim(); +} + +async function getRemoteBranchHead(branchName) { + const { stdout, exitCode, stderr } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { + ignoreReturnCode: true, + }); + if (exitCode !== 0) { + core.info(`Could not query remote branch ${branchName}: ${stderr.trim() || `exit code ${exitCode}`}`); + return ""; + } + const trimmed = stdout.trim(); + if (!trimmed) { + core.info(`Remote branch ${branchName} does not exist yet`); + return ""; + } + const remoteHead = trimmed.split(/\s+/)[0] || ""; + core.info(`Remote branch ${branchName} currently points to ${remoteHead}`); + return remoteHead; +} + +async function fetchRemoteBranch(branchName) { + core.info(`Fetching remote branch ${branchName} for comparison`); + await exec.exec("git", ["fetch", "origin", `refs/heads/${branchName}:refs/remotes/origin/${branchName}`]); +} + +async function filterFilesNeedingUpdate(comparisonRef, changedFiles, workspaceDir) { + const filesToUpdate = []; + for (const file of changedFiles) { + const workingTreePath = `${workspaceDir}/${file}`; + const workingTreeContent = fs.readFileSync(workingTreePath, "utf8"); + const { stdout, exitCode } = await exec.getExecOutput("git", ["show", `${comparisonRef}:${file}`], { + ignoreReturnCode: true, + }); + if (exitCode !== 0) { + core.info(`Remote ref ${comparisonRef} does not contain ${file}; scheduling update`); + filesToUpdate.push(file); + continue; + } + if (stdout !== workingTreeContent) { + core.info(`Detected updated compiled workflow content for ${file}`); + filesToUpdate.push(file); + continue; + } + core.info(`Compiled workflow file ${file} already matches ${comparisonRef}`); + } + return filesToUpdate; +} + +async function stageFiles(files) { + if (!Array.isArray(files) || files.length === 0) { + return; + } + await exec.exec("git", ["add", "--", ...files]); +} + +async function prepareAndPushRecompileBranch(owner, repo, changedFiles) { + const token = requireRecompileToken(); + const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); + const baseHead = await getLocalHeadSha(); + core.info(`Current repository HEAD before maintenance branch commit: ${baseHead}`); + + const remoteHead = await getRemoteBranchHead(RECOMPILE_PR_BRANCH); + let filesToCommit = changedFiles; + let baseRef = baseHead; + if (remoteHead) { + await fetchRemoteBranch(RECOMPILE_PR_BRANCH); + filesToCommit = await filterFilesNeedingUpdate(`refs/remotes/origin/${RECOMPILE_PR_BRANCH}`, changedFiles, workspaceDir); + baseRef = remoteHead; + } + + core.info(`Preparing maintenance branch ${RECOMPILE_PR_BRANCH}`); + await exec.exec("git", ["checkout", "-B", RECOMPILE_PR_BRANCH]); + + if (filesToCommit.length === 0) { + core.info("Existing maintenance branch already contains the latest compiled workflow lock files"); + return { pushed: false }; + } + + await stageFiles(filesToCommit); + core.info(`Staging ${filesToCommit.length} workflow lock file(s): ${filesToCommit.join(", ")}`); + await exec.exec("git", ["commit", "-m", "chore: recompile agentic workflows"]); + + core.info(`Pushing maintenance branch ${RECOMPILE_PR_BRANCH} via signed commit helper (baseRef=${baseRef})`); + await pushSignedCommits({ + githubClient: github, + owner, + repo, + branch: RECOMPILE_PR_BRANCH, + baseRef, + cwd: workspaceDir, + gitAuthEnv: getGitAuthEnv(token), + allowGitPushFallback: false, + }); + return { pushed: true }; +} + +async function findExistingRecompilePullRequest(owner, repo) { + core.info(`Searching for an existing maintenance PR from branch ${owner}:${RECOMPILE_PR_BRANCH}`); + const result = await github.rest.pulls.list({ + owner, + repo, + state: "open", + head: `${owner}:${RECOMPILE_PR_BRANCH}`, + per_page: 1, + }); + return result.data[0] || null; +} + +async function findExistingRecompileIssue(owner, repo) { + const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${RECOMPILE_ISSUE_TITLE}"`; + + core.info(`Searching for existing issue with title: "${RECOMPILE_ISSUE_TITLE}"`); + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + return searchResult.data.total_count > 0 ? searchResult.data.items[0] : null; +} + +async function handlePullRequest(owner, repo, changedFiles) { + const repository = `${owner}/${repo}`; + const runUrl = buildWorkflowRunUrl(context, context.repo); + core.info(`Preparing maintenance PR for ${repository}`); + const existingIssue = await findExistingRecompileIssue(owner, repo); + if (existingIssue) { + core.info(`Found existing issue #${existingIssue.number} to link from maintenance PR`); + } else { + core.info("No existing workflow recompile issue found to link from maintenance PR"); + } + const { pushed } = await prepareAndPushRecompileBranch(owner, repo, changedFiles); + const pullRequestBody = buildRecompilePullRequestBody(changedFiles, repository, runUrl, existingIssue?.number); + + const existingPR = await findExistingRecompilePullRequest(owner, repo); + if (existingPR) { + core.info(`Found existing pull request #${existingPR.number}: ${existingPR.html_url}`); + core.info(`Updating existing pull request #${existingPR.number} body`); + await github.rest.pulls.update({ + owner, + repo, + pull_number: existingPR.number, + body: pullRequestBody, + }); + const updateMessage = pushed ? "Updated existing pull request branch (avoiding duplicate)" : "Existing pull request already had the latest branch contents"; + core.info(updateMessage); + await core.summary + .addHeading("Workflow Recompilation Needed", 2) + .addRaw( + pushed + ? `Updated existing pull request [#${existingPR.number}](${existingPR.html_url}) with the latest compiled workflow changes.` + : `Existing pull request [#${existingPR.number}](${existingPR.html_url}) already contains the latest compiled workflow changes.` + ) + .write(); + return; + } + + core.info(`Creating maintenance pull request against repository default branch with ${changedFiles.length} changed file(s)`); + const defaultBranch = await getEffectiveBaseBranch(owner, repo); + const pullRequest = await github.rest.pulls.create({ + owner, + repo, + title: RECOMPILE_PR_TITLE, + head: RECOMPILE_PR_BRANCH, + base: defaultBranch, + body: pullRequestBody, + }); + + core.info(`✓ Created pull request #${pullRequest.data.number}: ${pullRequest.data.html_url}`); + await core.summary.addHeading("Workflow Recompilation Needed", 2).addRaw(`Created pull request [#${pullRequest.data.number}](${pullRequest.data.html_url}) to update compiled workflow lock files.`).write(); +} + /** - * Check if workflows need recompilation and create an issue if needed. + * Check if workflows need recompilation and create an issue or pull request if needed. * This script: * 1. Checks if there are out-of-sync workflow lock files * 2. Searches for existing open issues about recompiling workflows @@ -18,8 +260,10 @@ const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); async function main() { const owner = context.repo.owner; const repo = context.repo.repo; + const createPullRequest = shouldCreatePullRequest(); core.info("Checking for out-of-sync workflow lock files"); + logConfiguration(createPullRequest); // Execute git diff to check for changes in lock files let diffOutput = ""; @@ -54,6 +298,9 @@ async function main() { } core.info("⚠ Detected out-of-sync workflow lock files"); + core.info(`Workflow diff size from detection step: ${diffOutput.length} byte(s)`); + const changedFiles = await getChangedLockFiles(); + core.info(`Changed workflow lock file count: ${changedFiles.length}`); // Capture the actual diff for the issue body let detailedDiff = ""; @@ -68,21 +315,17 @@ async function main() { } catch (error) { core.warning(`Could not capture detailed diff: ${getErrorMessage(error)}`); } + core.info(`Detailed workflow diff captured: ${detailedDiff.length} byte(s)`); - // Search for existing open issue about workflow recompilation - const issueTitle = "[aw] agentic workflows out of sync"; - const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${issueTitle}"`; - - core.info(`Searching for existing issue with title: "${issueTitle}"`); + if (createPullRequest) { + requireRecompileToken(); + await handlePullRequest(owner, repo, changedFiles); + return; + } try { - const searchResult = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - if (searchResult.data.total_count > 0) { - const existingIssue = searchResult.data.items[0]; + const existingIssue = await findExistingRecompileIssue(owner, repo); + if (existingIssue) { core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); core.info("Skipping issue creation (avoiding duplicate)"); @@ -175,7 +418,7 @@ async function main() { const newIssue = await github.rest.issues.create({ owner, repo, - title: issueTitle, + title: RECOMPILE_ISSUE_TITLE, body: issueBody, labels: ["agentic-workflows", "maintenance"], }); @@ -190,4 +433,4 @@ async function main() { } } -module.exports = { main }; +module.exports = { main, buildRecompilePullRequestBody, shouldCreatePullRequest }; diff --git a/setup/js/create_check_run.cjs b/setup/js/create_check_run.cjs new file mode 100644 index 00000000..50ab7431 --- /dev/null +++ b/setup/js/create_check_run.cjs @@ -0,0 +1,196 @@ +// @ts-check +/// + +/** + * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction + */ + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { isStagedMode } = require("./safe_output_helpers.cjs"); +const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); +const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); +const { sanitizeContent } = require("./sanitize_content.cjs"); + +/** @type {string} Safe output type handled by this module */ +const HANDLER_TYPE = "create_check_run"; + +/** @type {Set} Valid conclusion values for GitHub Check Runs */ +const VALID_CONCLUSIONS = new Set(["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]); + +/** @type {number} Maximum length for summary and text fields (GitHub API limit) */ +const MAX_CONTENT_LENGTH = 65535; + +/** @type {number} Maximum length for the title field */ +const MAX_TITLE_LENGTH = 256; + +/** + * Main handler factory for create_check_run + * Returns a message handler function that processes individual create_check_run messages + * @type {HandlerFactoryFunction} + */ +async function main(config = {}) { + // Extract configuration + const configuredName = config.name || ""; + const maxCount = config.max != null ? Number(config.max) : 1; + const githubClient = await createAuthenticatedGitHubClient(config); + const isStaged = isStagedMode(config); + + // Optional config-level output defaults (sanitized at startup so we pay the cost once) + const configOutputTitle = config.output_title ? sanitizeContent(String(config.output_title), MAX_TITLE_LENGTH) : ""; + const configOutputSummary = config.output_summary ? sanitizeContent(String(config.output_summary), MAX_CONTENT_LENGTH) : ""; + + // Resolve the check run name: config > workflow name env var > fallback. + // Auto-deduplicate: if the resolved name equals the workflow name, GitHub's UI + // may collapse the programmatic check run into the workflow's own check suite + // entry, hiding it in compact/mobile views. Appending "(Result)" ensures a + // distinct name so the check run remains visible on all GitHub UI surfaces. + const workflowName = process.env.GITHUB_WORKFLOW || ""; + let defaultName = configuredName || workflowName || "Agent Check"; + if (defaultName === workflowName && workflowName) { + defaultName = `${defaultName} (Result)`; + } + + core.info(`Create check run configuration: name="${defaultName}", max=${maxCount}`); + if (configOutputTitle) core.info(`Config output.title fallback set (${configOutputTitle.length} chars)`); + if (configOutputSummary) core.info(`Config output.summary fallback set (${configOutputSummary.length} chars)`); + + // Track how many check runs we've created for max limit enforcement + let processedCount = 0; + + /** + * Message handler function that processes a single create_check_run message + * @param {Object} message - The create_check_run message to process + * @param {Object} _resolvedTemporaryIds - Map of temporary IDs (unused for check runs) + * @returns {Promise} Result with success/error status + */ + return async function handleCreateCheckRun(message, _resolvedTemporaryIds) { + // Check if we've hit the max limit + if (processedCount >= maxCount) { + core.warning(`Skipping create_check_run: max count of ${maxCount} reached`); + return { + success: false, + error: `Max count of ${maxCount} reached`, + }; + } + + // Validate required fields + const conclusion = message.conclusion; + if (!conclusion) { + const msg = "create_check_run requires a 'conclusion' field"; + core.error(msg); + return { success: false, error: msg }; + } + if (!VALID_CONCLUSIONS.has(conclusion)) { + const msg = `create_check_run: invalid conclusion '${conclusion}'. Must be one of: ${[...VALID_CONCLUSIONS].join(", ")}`; + core.error(msg); + return { success: false, error: msg }; + } + + // Resolve title: agent value (sanitized) > config fallback > error + const rawTitle = (message.title || "").trim(); + const resolvedTitle = rawTitle ? sanitizeContent(rawTitle, MAX_TITLE_LENGTH) : configOutputTitle; + if (!resolvedTitle) { + const msg = configOutputTitle ? "create_check_run: title resolved to empty after sanitization" : "create_check_run requires a non-empty 'title' field (or config output.title fallback)"; + core.error(msg); + return { success: false, error: msg }; + } + + // Resolve summary: agent value (sanitized + truncated) > config fallback > error + const rawSummary = (message.summary || "").trim(); + const resolvedSummary = rawSummary ? sanitizeContent(rawSummary, MAX_CONTENT_LENGTH) : configOutputSummary; + if (!resolvedSummary) { + const msg = configOutputSummary ? "create_check_run: summary resolved to empty after sanitization" : "create_check_run requires a non-empty 'summary' field (or config output.summary fallback)"; + core.error(msg); + return { success: false, error: msg }; + } + + // Sanitize optional text field + const rawText = (message.text || "").trim(); + const resolvedText = rawText ? sanitizeContent(rawText, MAX_CONTENT_LENGTH) : ""; + + const owner = context.repo.owner; + const repo = context.repo.repo; + + // For pull_request events, GITHUB_SHA is the ephemeral merge commit SHA which is + // not visible in the PR checks UI or the GitHub mobile app. Use the actual PR head + // SHA from the event payload instead so the check run appears on the PR. + const prHeadSha = context.payload?.pull_request?.head?.sha; + const headSha = prHeadSha || process.env.GITHUB_SHA || context.sha; + + if (!headSha) { + const msg = "create_check_run: cannot determine commit SHA for check run"; + core.error(msg); + return { success: false, error: msg }; + } + + if (prHeadSha) { + core.info(`Using PR head SHA ${prHeadSha} (pull_request event)`); + } + + const checkRunName = defaultName; + + core.info(`Creating check run "${checkRunName}" on ${owner}/${repo}@${headSha} with conclusion=${conclusion}`); + + // If in staged mode, preview without executing + if (isStaged) { + logStagedPreviewInfo(`Would create check run "${checkRunName}" with conclusion=${conclusion}, title="${resolvedTitle}"`); + processedCount++; + return { + success: true, + staged: true, + previewInfo: { + name: checkRunName, + conclusion, + title: resolvedTitle, + }, + }; + } + + try { + const output = { + title: resolvedTitle, + summary: resolvedSummary, + ...(resolvedText ? { text: resolvedText } : {}), + }; + + const response = await withRetry( + () => + githubClient.rest.checks.create({ + owner, + repo, + name: checkRunName, + head_sha: headSha, + status: "completed", + conclusion, + completed_at: new Date().toISOString(), + output, + }), + RATE_LIMIT_RETRY_CONFIG + ); + + const checkRunId = response.data.id; + const checkRunUrl = response.data.html_url; + + core.info(`✓ Created check run "${checkRunName}" #${checkRunId}: ${checkRunUrl}`); + processedCount++; + + return { + success: true, + check_run_id: checkRunId, + check_run_url: checkRunUrl, + conclusion, + name: checkRunName, + }; + } catch (error) { + const errorMessage = getErrorMessage(error); + core.error(`Failed to create check run "${checkRunName}": ${errorMessage}`); + return { + success: false, + error: errorMessage, + }; + } + }; +} + +module.exports = { main }; diff --git a/setup/js/create_pull_request.cjs b/setup/js/create_pull_request.cjs index 50d4a08c..66445199 100644 --- a/setup/js/create_pull_request.cjs +++ b/setup/js/create_pull_request.cjs @@ -27,7 +27,7 @@ const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { checkFileProtection } = require("./manifest_file_helpers.cjs"); -const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); +const { renderTemplateFromFile, renderFilesList, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); @@ -1061,10 +1061,15 @@ async function main(config = {}) { // Check file protection: allowlist (strict) or protected-files policy. /** @type {string[] | null} Protected files that trigger fallback-to-issue handling */ let manifestProtectionFallback = null; + /** @type {string[] | null} Protected files that trigger request-review handling */ + let manifestProtectionRequestReview = null; /** @type {unknown} */ let manifestProtectionPushFailedError = null; if (!isEmpty) { - const protection = checkFileProtection(patchContent, config); + const protection = checkFileProtection(patchContent, { + ...config, + protected_files_policy: config.protected_files_policy ?? "request_review", + }); if (protection.action === "deny") { const filesStr = protection.files.join(", "); const message = @@ -1078,6 +1083,10 @@ async function main(config = {}) { manifestProtectionFallback = protection.files; core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`); } + if (protection.action === "request_review") { + manifestProtectionRequestReview = protection.files; + core.warning(`Protected file protection triggered (request_review): ${protection.files.join(", ")}. Will create pull request with caution and request-changes review.`); + } } if (isEmpty && !isStaged && !allowEmpty) { @@ -1229,6 +1238,15 @@ async function main(config = {}) { bodyLines.unshift(...bodyHeader.split("\n"), ""); } + // Keep the protected-files notice directly under detection caution: + // this block runs first, then detectionCaution below unshifts to index 0. + if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { + const protectedFilesNoticeTemplatePath = getPromptPath("manifest_protection_request_review.md"); + const protectedFilesNotice = renderTemplateFromFile(protectedFilesNoticeTemplatePath, { + files: renderFilesList(manifestProtectionRequestReview.join(", ")), + }); + bodyLines.unshift(protectedFilesNotice, "", ""); + } // Inject CAUTION at top of body (unshifted after header so it appears first in the final output) const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); if (detectionCaution) { @@ -2032,6 +2050,60 @@ ${patchPreview}`; } } + const requestChangesSections = []; + if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { + const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md"); + requestChangesSections.push( + renderTemplateFromFile(protectedFilesReviewTemplatePath, { + files: renderFilesList(manifestProtectionRequestReview), + }) + ); + } + if (detectionCaution) { + const detectionReason = process.env.GH_AW_DETECTION_REASON || "unknown"; + const detectionWarningReviewTemplatePath = getPromptPath("threat_warning_request_changes_review.md"); + requestChangesSections.push( + renderTemplateFromFile(detectionWarningReviewTemplatePath, { + detectionReason, + runUrl, + }) + ); + } + if (requestChangesSections.length > 0) { + const requestChangesBody = requestChangesSections.join("\n\n---\n\n"); + /** @type {{ owner: string, repo: string, pull_number: number, event: "REQUEST_CHANGES" | "COMMENT", body: string, commit_id?: string }} */ + const requestChangesParams = { + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: pullRequest.number, + event: "REQUEST_CHANGES", + body: requestChangesBody, + }; + if (pullRequest.head && pullRequest.head.sha) { + requestChangesParams.commit_id = pullRequest.head.sha; + } + core.info(`Creating REQUEST_CHANGES review for PR #${pullRequest.number} due to protected files`); + try { + await withRetry(() => githubClient.rest.pulls.createReview(requestChangesParams), RATE_LIMIT_RETRY_CONFIG, `create REQUEST_CHANGES review for PR #${pullRequest.number}`); + core.info(`Created REQUEST_CHANGES review for PR #${pullRequest.number}`); + } catch (requestChangesError) { + const requestChangesErrorMessage = getErrorMessage(requestChangesError); + const ownPrMessages = ["Can not request changes on your own pull request"]; + if (ownPrMessages.some(msg => requestChangesErrorMessage.includes(msg))) { + core.warning(`Cannot submit REQUEST_CHANGES on own PR #${pullRequest.number}. Retrying with COMMENT.`); + try { + const commentReviewParams = { ...requestChangesParams, event: "COMMENT" }; + await withRetry(() => githubClient.rest.pulls.createReview(commentReviewParams), RATE_LIMIT_RETRY_CONFIG, `create COMMENT review fallback for PR #${pullRequest.number}`); + core.info(`Created COMMENT review fallback for PR #${pullRequest.number}`); + } catch (commentReviewError) { + core.warning(`Failed to create COMMENT review fallback for PR #${pullRequest.number}: ${commentReviewError instanceof Error ? commentReviewError.message : String(commentReviewError)}`); + } + } else { + core.warning(`Failed to create REQUEST_CHANGES review for PR #${pullRequest.number}: ${requestChangesErrorMessage}`); + } + } + } + // Enable auto-merge if configured if (autoMerge) { try { diff --git a/setup/js/generate_git_patch.cjs b/setup/js/generate_git_patch.cjs index 0c0234f8..6e2b2222 100644 --- a/setup/js/generate_git_patch.cjs +++ b/setup/js/generate_git_patch.cjs @@ -204,8 +204,23 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } } + // If origin/ is unavailable (e.g. credentials were cleaned), + // fall back to the local base branch ref when it exists. + let defaultBranchRef = null; if (hasLocalDefaultBranch) { - baseRef = execGitSync(["merge-base", "--", `origin/${defaultBranch}`, branchName], { cwd }).trim(); + defaultBranchRef = `origin/${defaultBranch}`; + } else { + try { + execGitSync(["show-ref", "--verify", "--quiet", `refs/heads/${defaultBranch}`], { cwd }); + defaultBranchRef = defaultBranch; + debugLog(`Strategy 1 (full): Using local branch ${defaultBranch} as fallback base ref`); + } catch { + // No local branch fallback either + } + } + + if (defaultBranchRef) { + baseRef = execGitSync(["merge-base", "--", defaultBranchRef, branchName], { cwd }).trim(); debugLog(`Strategy 1 (full): Computed merge-base: ${baseRef}`); } else { // No remote refs available - fall through to Strategy 2 diff --git a/setup/js/generate_safe_outputs_tools.cjs b/setup/js/generate_safe_outputs_tools.cjs index f88960ae..68b1be55 100644 --- a/setup/js/generate_safe_outputs_tools.cjs +++ b/setup/js/generate_safe_outputs_tools.cjs @@ -1,6 +1,7 @@ // @ts-check /// "use strict"; +// @safe-outputs-exempt SEC-004 — schema generator; does not process user body content. The substring "body:" appears only in the comment referencing the "allow-body" config option. /** * generate_safe_outputs_tools.cjs diff --git a/setup/js/get_base_branch.cjs b/setup/js/get_base_branch.cjs index 408df8ef..5025d62d 100644 --- a/setup/js/get_base_branch.cjs +++ b/setup/js/get_base_branch.cjs @@ -13,7 +13,7 @@ const { execGitSync } = require("./git_helpers.cjs"); * 2. github.base_ref env var (set for pull_request/pull_request_target events) * 3. Pull request payload base ref (pull_request_review, pull_request_review_comment events) * 4. API lookup for issue_comment events on PRs (the PR's base ref is not in the payload) - * 5. Checked-out branch from git (opt-in; used by safeoutputs for side-repo operations) + * 5. Repository default branch from local git remote HEAD (opt-in for side-repo operations) * 6. context.payload.repository.default_branch (included in most event payloads, no API call) * 6b. API lookup via repos.get() when payload doesn't have it (e.g. cross-repo scenarios) * 7. Fallback to DEFAULT_BRANCH env var or "main" @@ -22,9 +22,11 @@ const { execGitSync } = require("./git_helpers.cjs"); * If provided, the issue_comment PR lookup (step 4) and repository default-branch * lookup (step 6b) use this instead of context.repo, which is needed for * cross-repo scenarios where the target repo differs from the workflow repository. - * @param {{preferCheckedOutBranch?: boolean, cwd?: string}|null} [options] - Optional resolution hints. - * When preferCheckedOutBranch is true and cwd is set, git is queried directly for the - * checked-out branch in that repository before falling back to the repository default branch. + * @param {{preferLocalDefaultBranchMetadata?: boolean, preferCheckedOutBranch?: boolean, cwd?: string}|null} [options] - Optional resolution hints. + * When preferLocalDefaultBranchMetadata is true and cwd is set, git is queried for + * refs/remotes/origin/HEAD in that repository to derive the repository default branch + * before falling back to payload/API-based default branch resolution. + * preferCheckedOutBranch is a deprecated alias kept for backward compatibility. * @returns {Promise} The base branch name */ async function getBaseBranch(targetRepo = null, options = null) { @@ -80,21 +82,25 @@ async function getBaseBranch(targetRepo = null, options = null) { } } - // 5. Use the actual checked-out branch when explicitly requested by the caller. - // This is primarily used by safeoutputs in side-repo workflows where the agent - // re-anchors the checkout to a non-default release branch before generating the patch. - if (options?.preferCheckedOutBranch && options.cwd) { + // 5. Resolve repository default branch from local git metadata when explicitly + // requested by the caller (side-repo workflows). This avoids using the current + // checked-out branch (which may be a newly-created feature branch). + const preferLocalDefaultBranchMetadata = options?.preferLocalDefaultBranchMetadata ?? options?.preferCheckedOutBranch; + const gitCwd = options?.cwd; + if (preferLocalDefaultBranchMetadata && gitCwd) { try { - const checkedOutBranch = execGitSync(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: options.cwd, + const symbolicRef = execGitSync(["symbolic-ref", "refs/remotes/origin/HEAD"], { + cwd: gitCwd, stdio: ["pipe", "pipe", "pipe"], + // Missing origin/HEAD is expected in some side-repo checkouts; avoid noisy annotations. + suppressLogs: true, }).trim(); - if (checkedOutBranch && checkedOutBranch !== "HEAD") { - return checkedOutBranch; + if (symbolicRef.startsWith("refs/remotes/origin/")) { + return symbolicRef.slice("refs/remotes/origin/".length); } } catch (/** @type {any} */ error) { if (typeof core !== "undefined" && typeof core.debug === "function") { - core.debug(`Failed to detect checked-out branch from git, falling back to repository default branch: ${getErrorMessage(error)}`); + core.debug(`Failed to resolve repository default branch from refs/remotes/origin/HEAD, falling back to payload/API lookup: ${getErrorMessage(error)}`); } // Ignore and continue with default branch resolution } diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index d210f45c..b4033547 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -1598,6 +1598,209 @@ function buildEngineFailureContext() { } } +/** Cascade detection constants */ +const CASCADE_WINDOW_MINUTES = 60; +const CASCADE_WINDOW_MS = CASCADE_WINDOW_MINUTES * 60 * 1000; +const CASCADE_THRESHOLD = 10; +const CASCADE_ROLLUP_TITLE = "[aw] Failure cascade detected"; +const CASCADE_LABEL = "cascade-suspected"; +const CASCADE_ROLLUP_LABEL = "cascade-rollup"; +/** Matches the exact title pattern produced by handle_agent_failure for individual failure issues */ +const FAILURE_TITLE_PATTERN = /^\[aw\] .+ failed$/; + +/** + * Ensure a GitHub label exists in the repository, creating it with a deterministic + * pastel color if it does not exist yet. Failures are non-fatal (logged as warnings). + * @param {string} owner + * @param {string} repo + * @param {string} labelName + * @returns {Promise} + */ +async function ensureLabelExists(owner, repo, labelName) { + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (err) { + // 404 → label does not exist, create it + const statusCode = err && typeof err === "object" && "status" in err ? /** @type {any} */ err.status : undefined; + if (statusCode !== 404) { + core.warning(`Could not check label "${labelName}": ${getErrorMessage(err)}`); + return; + } + try { + // Derive a deterministic pastel color from the label name + let hash = 0; + for (let i = 0; i < labelName.length; i++) { + hash = (hash * 31 + labelName.charCodeAt(i)) >>> 0; + } + const r = 128 + (hash & 0x3f); + const g = 128 + ((hash >> 6) & 0x3f); + const b = 128 + ((hash >> 12) & 0x3f); + const color = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); + await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); + core.info(`✓ Created label "${labelName}" (#${color})`); + } catch (createErr) { + core.warning(`Could not create label "${labelName}": ${getErrorMessage(createErr)}`); + } + } +} + +/** + * Detect whether a failure cascade is active by counting `[aw] * failed` issues + * created within the last CASCADE_WINDOW_MINUTES minutes. + * + * @param {string} owner + * @param {string} repo + * @returns {Promise>} + * Issues that belong to the cascade window (may be empty). + */ +async function findRecentFailureIssues(owner, repo) { + const windowStart = new Date(Date.now() - CASCADE_WINDOW_MS); + const since = windowStart.toISOString().slice(0, 19) + "Z"; // e.g. "2026-05-22T02:00:00Z" + + // GitHub search API supports `created:>=YYYY-MM-DDTHH:MM:SSZ` + const searchQuery = `repo:${owner}/${repo} is:issue is:open label:agentic-workflows "[aw]" "failed" in:title created:>=${since}`; + + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 100, + sort: "created", + order: "asc", + }); + return result.data.items + .filter(item => FAILURE_TITLE_PATTERN.test(item.title)) + .map(item => ({ + number: item.number, + title: item.title, + html_url: item.html_url, + created_at: item.created_at, + })); + } catch (err) { + core.warning(`Could not query recent failure issues for cascade detection: ${getErrorMessage(err)}`); + return []; + } +} + +/** + * Find an existing open cascade rollup issue. + * @param {string} owner + * @param {string} repo + * @returns {Promise<{number: number, html_url: string} | null>} + */ +async function findExistingCascadeRollupIssue(owner, repo) { + const searchQuery = `repo:${owner}/${repo} is:issue is:open label:${CASCADE_ROLLUP_LABEL} in:title "${CASCADE_ROLLUP_TITLE}"`; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + if (result.data.total_count > 0) { + const item = result.data.items[0]; + return { number: item.number, html_url: item.html_url }; + } + } catch (err) { + core.warning(`Could not search for cascade rollup issue: ${getErrorMessage(err)}`); + } + return null; +} + +/** + * Detect a failure cascade and, when one is active, create/update a rollup issue + * and add the `cascade-suspected` label to every issue in the cascade window + * (including the issue that was just created/updated, identified by `triggeringIssueNumber`). + * + * A cascade is active when ≥CASCADE_THRESHOLD `[aw] * failed` issues were filed + * within the last CASCADE_WINDOW_MINUTES minutes. + * + * @param {string} owner + * @param {string} repo + * @param {number} triggeringIssueNumber - The issue just created or updated by this run + * @returns {Promise} + */ +async function detectAndHandleFailureCascade(owner, repo, triggeringIssueNumber) { + try { + const recentIssues = await findRecentFailureIssues(owner, repo); + + // Ensure the triggering issue is included even if GitHub search indexing lags + const issueNumbers = new Set(recentIssues.map(i => i.number)); + issueNumbers.add(triggeringIssueNumber); + + if (issueNumbers.size < CASCADE_THRESHOLD) { + core.info(`Cascade check: ${issueNumbers.size} failure issue(s) in the last ${CASCADE_WINDOW_MINUTES} min (threshold: ${CASCADE_THRESHOLD}) — no cascade`); + return; + } + + core.info(`⚠️ Cascade detected: ${issueNumbers.size} failure issues in the last ${CASCADE_WINDOW_MINUTES} min — creating rollup and labeling individual issues`); + + // Ensure required labels exist + await ensureLabelExists(owner, repo, CASCADE_LABEL); + await ensureLabelExists(owner, repo, CASCADE_ROLLUP_LABEL); + + // Build rollup body + const affectedList = recentIssues.map(i => `- [#${i.number}](${i.html_url}) — ${i.title}`).join("\n"); + const windowStart = new Date(Date.now() - CASCADE_WINDOW_MS); + const rollupBody = [ + `## ⚠️ Failure Cascade Detected`, + ``, + `**${issueNumbers.size} \`[aw] * failed\` issues** were filed within the last **${CASCADE_WINDOW_MINUTES} minutes** (since ${windowStart.toUTCString()}).`, + ``, + `This volume suggests a common root cause (e.g., lockfile drift, provider outage, infrastructure change) rather than isolated workflow failures.`, + ``, + `### Affected Workflows`, + ``, + affectedList || `_(none indexed yet — search indexing may lag)_`, + ``, + `### What to Do`, + ``, + `1. Identify the common root cause (check recent infra changes, provider status, lockfile drift).`, + `2. Once the root cause is resolved, batch-close the \`${CASCADE_LABEL}\`-labeled issues.`, + `3. Close this rollup issue when the cascade is resolved.`, + ``, + `### Labels`, + ``, + `Each individual failure issue in the cascade window has been labeled \`${CASCADE_LABEL}\` so you can filter and batch-close them once the root cause is patched.`, + ].join("\n"); + + // Create or update the cascade rollup issue + const existing = await findExistingCascadeRollupIssue(owner, repo); + if (existing) { + await github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + body: rollupBody, + }); + core.info(`✓ Updated cascade rollup issue #${existing.number}: ${existing.html_url}`); + } else { + const newRollup = await github.rest.issues.create({ + owner, + repo, + title: CASCADE_ROLLUP_TITLE, + body: rollupBody, + labels: ["agentic-workflows", CASCADE_ROLLUP_LABEL], + }); + core.info(`✓ Created cascade rollup issue #${newRollup.data.number}: ${newRollup.data.html_url}`); + } + + // Add cascade-suspected label to every issue in the window + for (const number of issueNumbers) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: [CASCADE_LABEL], + }); + core.info(`✓ Labeled issue #${number} with "${CASCADE_LABEL}"`); + } catch (labelErr) { + core.warning(`Could not label issue #${number} with "${CASCADE_LABEL}": ${getErrorMessage(labelErr)}`); + } + } + } catch (err) { + core.warning(`Cascade detection failed (non-fatal): ${getErrorMessage(err)}`); + } +} + /** * Handle agent job failure by creating or updating a failure tracking issue * This script is called from the conclusion job when the agent job has failed @@ -2146,6 +2349,9 @@ async function main() { }); core.info(`✓ Added comment to existing issue #${existingIssue.number}`); + + // Cascade detection: check for storm of failures within the window + await detectAndHandleFailureCascade(owner, repo, existingIssue.number); } else { // No existing issue, create a new one core.info("No existing issue found, creating a new one"); @@ -2343,6 +2549,9 @@ async function main() { // Continue even if linking fails } } + + // Cascade detection: check for storm of failures within the window + await detectAndHandleFailureCascade(owner, repo, newIssue.data.number); } } catch (error) { core.warning(`Failed to create or update failure tracking issue: ${getErrorMessage(error)}`); @@ -2378,4 +2587,13 @@ module.exports = { parseEffectiveTokensErrorInfoFromAuditLog, getActionFailureIssueExpiresHours, hasAgentTerminalReasonCompleted, + detectAndHandleFailureCascade, + findRecentFailureIssues, + CASCADE_WINDOW_MINUTES, + CASCADE_WINDOW_MS, + CASCADE_THRESHOLD, + CASCADE_LABEL, + CASCADE_ROLLUP_LABEL, + CASCADE_ROLLUP_TITLE, + FAILURE_TITLE_PATTERN, }; diff --git a/setup/js/manifest_file_helpers.cjs b/setup/js/manifest_file_helpers.cjs index 81d2beb4..8098ed1a 100644 --- a/setup/js/manifest_file_helpers.cjs +++ b/setup/js/manifest_file_helpers.cjs @@ -193,7 +193,8 @@ function checkForTopLevelDotFolders(patchContent, excludes) { * The checks are applied in order and all must pass: * 1. If `allowed_files` is set → every file in the patch must match at least one pattern (deny if not). * 2. `protected-files` policy applies independently: "allowed" = skip, - * "fallback-to-issue" = create review issue, default ("blocked") = deny. + * "fallback-to-issue" = create review issue, "request_review" = create PR with + * request-changes review, default ("blocked") = deny. * * To allow an agent to write protected files, set both `allowed-files` (strict scope) and * `protected-files: allowed` (explicit permission) — neither overrides the other implicitly. @@ -204,7 +205,7 @@ function checkForTopLevelDotFolders(patchContent, excludes) { * * @param {string} patchContent - The git patch content * @param {HandlerConfig} config - * @returns {{ action: 'allow' } | { action: 'deny', source: 'allowlist'|'protected', files: string[] } | { action: 'fallback', files: string[] }} + * @returns {{ action: 'allow' } | { action: 'deny', source: 'allowlist'|'protected', files: string[] } | { action: 'fallback', files: string[] } | { action: 'request_review', files: string[] }} */ function checkFileProtection(patchContent, config) { // Step 1: allowlist check (if configured) @@ -233,7 +234,13 @@ function checkFileProtection(patchContent, config) { return { action: "allow" }; } - return config.protected_files_policy === "fallback-to-issue" ? { action: "fallback", files: allFound } : { action: "deny", source: "protected", files: allFound }; + if (config.protected_files_policy === "fallback-to-issue") { + return { action: "fallback", files: allFound }; + } + if (config.protected_files_policy === "request_review") { + return { action: "request_review", files: allFound }; + } + return { action: "deny", source: "protected", files: allFound }; } module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkForTopLevelDotFolders, checkAllowedFiles, checkExcludedFiles, checkFileProtection }; diff --git a/setup/js/mcp_server_core.cjs b/setup/js/mcp_server_core.cjs index 2bf3dc99..05b96539 100644 --- a/setup/js/mcp_server_core.cjs +++ b/setup/js/mcp_server_core.cjs @@ -539,6 +539,24 @@ function normalizeTool(name) { return name.replace(/-/g, "_").toLowerCase(); } +/** + * Detect local file reference notation in tool arguments (e.g. "@/tmp/file.md", "@./file.txt"). + * @param {unknown} value + * @returns {boolean} + */ +function containsAtFilepathReference(value) { + if (typeof value === "string") { + return /(?:^|\s)@(?:\/|\.{1,2}\/)[^\s]+/.test(value); + } + if (Array.isArray(value)) { + return value.some(item => containsAtFilepathReference(item)); + } + if (value && typeof value === "object") { + return Object.values(value).some(item => containsAtFilepathReference(item)); + } + return false; +} + /** * Handle an incoming JSON-RPC request and return a response (for HTTP transport) * This function is compatible with the MCPServer class's handleRequest method. @@ -592,6 +610,12 @@ async function handleRequest(server, request, defaultHandler) { message: "Invalid params: 'name' must be a string", }; } + if (containsAtFilepathReference(args)) { + throw { + code: -32602, + message: "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.", + }; + } const tool = server.tools[normalizeTool(name)]; if (!tool) { // Find similar tools to suggest @@ -731,6 +755,10 @@ async function handleMessage(server, req, defaultHandler) { server.replyError(id, -32602, "Invalid params: 'name' must be a string"); return; } + if (containsAtFilepathReference(args)) { + server.replyError(id, -32602, "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead."); + return; + } const tool = server.tools[normalizeTool(name)]; if (!tool) { // Find similar tools to suggest diff --git a/setup/js/messages_core.cjs b/setup/js/messages_core.cjs index 5a0d7b54..120d217d 100644 --- a/setup/js/messages_core.cjs +++ b/setup/js/messages_core.cjs @@ -81,10 +81,39 @@ function getMessages() { function renderTemplate(template, context) { return template.replace(/\{(\w+)\}/g, (match, key) => { const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; + if (value === undefined || value === null) { + return match; + } + return String(value); }); } +/** + * Render a comma-separated files list into markdown inline code spans. + * - Trims each entry and drops empty segments + * - Accepts filenames already wrapped in backticks + * - Redacts unsafe/invalid entries as `redacted` + * @param {string[]|string|number|boolean} value + * @returns {string} + */ +function renderFilesList(value) { + const files = Array.isArray(value) ? value : String(value).split(","); + const normalizedFiles = files.map(file => String(file).trim()).filter(Boolean); + + return normalizedFiles + .map(file => { + let normalized = file; + if (normalized.startsWith("`") && normalized.endsWith("`")) { + normalized = normalized.slice(1, -1).trim(); + } + if (!normalized || normalized.includes("`")) { + normalized = "redacted"; + } + return `\`${normalized}\``; + }) + .join(", "); +} + /** * Resolve the absolute path to a prompt template file. * Prefers GH_AW_PROMPTS_DIR when set, otherwise falls back to @@ -188,6 +217,7 @@ module.exports = { getMessages, getPromptPath, renderTemplate, + renderFilesList, renderTemplateFromFile, toSnakeCase, encodePathSegments, diff --git a/setup/js/model_multipliers.json b/setup/js/model_multipliers.json index fb442e29..059f2042 100644 --- a/setup/js/model_multipliers.json +++ b/setup/js/model_multipliers.json @@ -1,6 +1,6 @@ { "version": "1", - "description": "Effective Tokens (ET) computation data per the gh-aw Effective Tokens Specification v0.2.0. Token class weights are applied first to normalize across token classes, then the per-model multiplier scales the result relative to the reference model. Model lifecycle: deprecated models must carry a deprecated marker for one minor version before removal (R-REG-009).", + "description": "Effective Tokens (ET) computation data per the gh-aw Effective Tokens Specification v0.2.0. Token class weights are applied first to normalize across token classes, then the per-model multiplier scales the result relative to the reference model. The registry keeps complete model history; entries are removed only by explicit manual deletion.", "reference_model": "claude-sonnet-4.5", "token_class_weights": { "input": 1.0, @@ -143,35 +143,5 @@ "gemma-4-31b-it": 0.2, "grok-code-fast-1": 0.33, "raptor-mini": 0.33 - }, - "deprecated_models": { - "claude-3-5-haiku": true, - "claude-3-5-opus": true, - "claude-3-5-sonnet": true, - "claude-3-7-sonnet": true, - "claude-3-haiku": true, - "claude-3-opus": true, - "claude-3-sonnet": true, - "claude-haiku-4-5": true, - "claude-haiku-4.5": true, - "claude-opus-4": true, - "claude-opus-4-1": true, - "claude-opus-4-5": true, - "claude-opus-4.5": true, - "claude-opus-4.6": true, - "claude-sonnet-4": true, - "claude-sonnet-4-5": true, - "claude-sonnet-4.5": true, - "claude-sonnet-4.6": true, - "gemini-1.5-flash": true, - "gemini-1.5-pro": true, - "gemini-2.5-flash-native-audio-preview-12-2025": true, - "gpt-4": true, - "gpt-4-turbo": true, - "gpt-5": true, - "gpt-5-chat-latest": true, - "gpt-5-mini": true, - "gpt-5-nano": true, - "gpt-5-pro": true } } diff --git a/setup/js/pi_provider.cjs b/setup/js/pi_provider.cjs index 6a449cc8..12f5db9d 100644 --- a/setup/js/pi_provider.cjs +++ b/setup/js/pi_provider.cjs @@ -82,6 +82,92 @@ function resolveGatewayUrl(provider) { return `http://api-proxy:${port}`; } +/** + * Join a base URL and relative API path without duplicating slashes. + * + * @param {string} baseUrl + * @param {string} apiPath + * @returns {string} + */ +function joinApiUrl(baseUrl, apiPath) { + return `${baseUrl.replace(/\/+$/, "")}${apiPath}`; +} + +/** + * Resolve the Pi model's inferred provider request target for logging. + * + * @param {any} model + * @returns {{ api: string, method: string, url: string }} + */ +function resolveProviderRequestTarget(model) { + const api = typeof model?.api === "string" && model.api ? model.api : "(unknown api)"; + const method = "POST"; + const baseUrl = typeof model?.baseUrl === "string" && model.baseUrl ? model.baseUrl : ""; + + if (!baseUrl) { + return { api, method, url: "(baseUrl unavailable)" }; + } + + switch (api) { + case "openai-completions": + return { api, method, url: joinApiUrl(baseUrl, "/chat/completions") }; + case "openai-responses": + case "azure-openai-responses": + case "openai-codex-responses": + return { api, method, url: joinApiUrl(baseUrl, "/responses") }; + case "anthropic": + case "anthropic-messages": + return { api, method, url: joinApiUrl(baseUrl, "/messages") }; + case "mistral-conversations": + return { api, method, url: joinApiUrl(baseUrl, "/conversations") }; + default: + return { api, method, url: baseUrl }; + } +} + +/** + * Format response header names for logs without printing sensitive values. + * + * @param {Record|undefined|null} headers + * @returns {string} + */ +function formatResponseHeaderNames(headers) { + const names = Object.keys(headers || {}) + .map(name => String(name).toLowerCase()) + .sort(); + return names.length > 0 ? names.join(",") : "none"; +} + +/** + * Log extra context when the AWF /reflect call does not produce a snapshot. + * + * @param {{ + * phase: string, + * provider: string, + * model: string, + * result: { + * ok: boolean, + * reflectUrl: string, + * outputPath: string, + * reason?: string, + * status?: number, + * error?: string, + * }, + * logger: (msg: string) => void, + * }} params + * @returns {void} + */ +function logReflectFailure(params) { + const { phase, provider, model, result, logger } = params; + if (!result || result.ok) { + return; + } + + const status = typeof result.status === "number" ? ` status=${result.status}` : ""; + const error = result.error ? ` error=${JSON.stringify(result.error)}` : ""; + logger(`reflect_failure phase=${phase} provider=${provider || "(no provider prefix)"} model=${model || "(not set)"} url=${result.reflectUrl} output=${result.outputPath} reason=${result.reason || "unknown"}${status}${error}`); +} + /** * Register a Pi provider and any aliases. * @@ -175,8 +261,44 @@ function registerConfiguredProviders(pi, logger) { */ function piProviderExtension(pi) { const log = DEFAULT_LOGGER; + /** @type {{ api: string, method: string, url: string }|null} */ + let lastProviderRequest = null; + /** @type {{ status: number, responseHeaders: string }|null} */ + let lastProviderResponse = null; registerConfiguredProviders(pi, log); + pi.on("before_provider_request", (_event, ctx) => { + lastProviderRequest = resolveProviderRequestTarget(ctx && ctx.model); + lastProviderResponse = null; + const provider = ctx?.model?.provider || "(unknown provider)"; + const model = ctx?.model?.id || getConfiguredModel() || "(unknown model)"; + log(`provider_request provider=${provider} model=${model} api=${lastProviderRequest.api} method=${lastProviderRequest.method} url=${lastProviderRequest.url}`); + }); + + pi.on("after_provider_response", (event, ctx) => { + const request = lastProviderRequest || resolveProviderRequestTarget(ctx && ctx.model); + lastProviderResponse = { + status: event.status, + responseHeaders: formatResponseHeaderNames(event.headers), + }; + const provider = ctx?.model?.provider || "(unknown provider)"; + const model = ctx?.model?.id || getConfiguredModel() || "(unknown model)"; + log(`provider_response provider=${provider} model=${model} status=${event.status} method=${request.method} url=${request.url} response_headers=${lastProviderResponse.responseHeaders}`); + }); + + pi.on("message_end", event => { + const message = event && event.message; + if (message?.role !== "assistant" || message?.stopReason !== "error" || !message?.errorMessage) { + return; + } + const request = lastProviderRequest || { api: message.api || "(unknown api)", method: "POST", url: "(request unavailable)" }; + const status = lastProviderResponse ? String(lastProviderResponse.status) : "no-response"; + const responseHeaders = lastProviderResponse ? lastProviderResponse.responseHeaders : "none"; + log( + `provider_error provider=${message.provider || "(unknown provider)"} model=${message.model || "(unknown model)"} api=${request.api} status=${status} method=${request.method} url=${request.url} response_headers=${responseHeaders} error=${JSON.stringify(message.errorMessage)}` + ); + }); + pi.on("agent_start", async () => { const model = getConfiguredModel(); const provider = extractProviderFromModel(model); @@ -196,13 +318,14 @@ function piProviderExtension(pi) { // This is best-effort: failures are logged but do not affect the agent session. // Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running). if (process.env.AWF_REFLECT_ENABLED === "1") { - await fetchAWFReflect({ + const result = await fetchAWFReflect({ reflectUrl: AWF_API_PROXY_REFLECT_URL, outputPath: AWF_REFLECT_OUTPUT_PATH, timeoutMs: AWF_REFLECT_TIMEOUT_MS, modelsTimeoutMs: AWF_MODELS_URL_TIMEOUT_MS, logger: log, }); + logReflectFailure({ phase: "agent_start", provider, model, result, logger: log }); } }); @@ -211,13 +334,16 @@ function piProviderExtension(pi) { // This is best-effort: failures are logged but do not affect the agent exit code. // Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running). if (process.env.AWF_REFLECT_ENABLED === "1") { - await fetchAWFReflect({ + const model = getConfiguredModel(); + const provider = extractProviderFromModel(model); + const result = await fetchAWFReflect({ reflectUrl: AWF_API_PROXY_REFLECT_URL, outputPath: AWF_REFLECT_OUTPUT_PATH, timeoutMs: AWF_REFLECT_TIMEOUT_MS, modelsTimeoutMs: AWF_MODELS_URL_TIMEOUT_MS, logger: log, }); + logReflectFailure({ phase: "agent_end", provider, model, result, logger: log }); } }); } @@ -227,3 +353,6 @@ module.exports.getConfiguredModel = getConfiguredModel; module.exports.extractProviderFromModel = extractProviderFromModel; module.exports.resolveGatewayUrl = resolveGatewayUrl; module.exports.registerConfiguredProviders = registerConfiguredProviders; +module.exports.resolveProviderRequestTarget = resolveProviderRequestTarget; +module.exports.formatResponseHeaderNames = formatResponseHeaderNames; +module.exports.logReflectFailure = logReflectFailure; diff --git a/setup/js/push_signed_commits.cjs b/setup/js/push_signed_commits.cjs index 4f8edc3a..2b89cef3 100644 --- a/setup/js/push_signed_commits.cjs +++ b/setup/js/push_signed_commits.cjs @@ -207,11 +207,12 @@ async function resolveLocalHeadSha(cwd) { * @param {string} opts.cwd - Working directory of the local git checkout * @param {object} [opts.gitAuthEnv] - Environment variables for git push fallback auth * @param {boolean} [opts.signedCommits=true] - When false, skip GraphQL signed commits and use git push directly + * @param {boolean} [opts.allowGitPushFallback=true] - When false, refuse any fallback path that would use direct git push * @param {Record} [opts.resolvedTemporaryIds] - Resolved temporary IDs map * @param {string} [opts.currentRepo] - Repository slug used for same-repo temporary ID resolution * @returns {Promise} SHA of the commit that landed on the target branch */ -async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, resolvedTemporaryIds, currentRepo }) { +async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo }) { const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`; const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, { defaultRepo: effectiveCurrentRepo, @@ -234,6 +235,9 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // The GraphQL createCommitOnBranch path cannot handle root commits (no parent to resolve), // so skip it entirely and fall directly through to git push. if (!baseRef) { + if (allowGitPushFallback === false) { + throw new Error(`pushSignedCommits: cannot push branch '${branch}' without a baseRef when git push fallback is disabled. ` + `Seed the branch with a signed commit first, then retry.`); + } core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`); try { const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); @@ -500,6 +504,9 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c { cause: err } ); } + if (allowGitPushFallback === false) { + throw new Error(`pushSignedCommits: signed commit push failed for branch '${branch}' and git push fallback is disabled: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); + } core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${err instanceof Error ? err.message : String(err)}`); const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); core.info(`pushSignedCommits: git push fallback completed, using pushed SHA ${fallbackSha}`); diff --git a/setup/js/safe_output_handler_manager.cjs b/setup/js/safe_output_handler_manager.cjs index 50a30223..fe9eed58 100644 --- a/setup/js/safe_output_handler_manager.cjs +++ b/setup/js/safe_output_handler_manager.cjs @@ -67,6 +67,7 @@ const HANDLER_MAP = { create_agent_session: "./create_agent_session.cjs", create_code_scanning_alert: "./create_code_scanning_alert.cjs", autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs", + create_check_run: "./create_check_run.cjs", dispatch_workflow: "./dispatch_workflow.cjs", dispatch_repository: "./dispatch_repository.cjs", call_workflow: "./call_workflow.cjs", @@ -129,6 +130,7 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "create_project_status_update", "update_release", "create_code_scanning_alert", + "create_check_run", "create_missing_tool_issue", "missing_tool", "create_missing_data_issue", diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index 74851ee7..4d8e317a 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -376,12 +376,12 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Get base branch for the resolved target repository. // Prefer explicit safe-output config value when provided, otherwise fall back // to dynamic resolution from trigger context/default branch. For side-repo - // checkouts, prefer the actual checked-out branch before the repository default - // branch so release-branch workflows generate patches against the right base. + // checkouts, prefer repository default-branch resolution from local + // origin/HEAD metadata before payload/API fallback. const baseBranch = prConfig.base_branch || (await getBaseBranch(repoParts, { - preferCheckedOutBranch: Boolean(repoCwd), + preferLocalDefaultBranchMetadata: Boolean(repoCwd), cwd: repoCwd || undefined, })); @@ -671,10 +671,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { } // Get base branch for the resolved target repository. - // For side-repo checkouts, prefer the actual checked-out branch before falling - // back to the repository default branch. + // For side-repo checkouts, prefer repository default-branch resolution from + // local origin/HEAD metadata before payload/API fallback. const baseBranch = await getBaseBranch(repoParts, { - preferCheckedOutBranch: Boolean(repoCwd), + preferLocalDefaultBranchMetadata: Boolean(repoCwd), cwd: repoCwd || undefined, }); diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 22f1c0b7..30407052 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -1,18 +1,18 @@ [ { "name": "create_issue", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead.", "inputSchema": { "type": "object", "required": ["title", "body"], "properties": { "title": { "type": "string", - "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title — not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." + "description": "Concise issue title summarizing the bug, feature, or task. Must be the final intended title \u2014 not a placeholder or test value. The title appears as the main heading, so keep it brief and descriptive." }, "body": { "type": "string", - "description": "Detailed issue description in Markdown. Must be the final intended body — not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." + "description": "Detailed issue description in Markdown. Must be the final intended body \u2014 not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." }, "labels": { "type": "array", @@ -245,14 +245,14 @@ }, { "name": "add_comment", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema — the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead.", "inputSchema": { "type": "object", "required": ["body"], "properties": { "body": { "type": "string", - "description": "The comment text in Markdown format. Must be the final intended comment — not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation." + "description": "The comment text in Markdown format. Must be the final intended comment \u2014 not a placeholder or test value. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation." }, "item_number": { "type": ["number", "string"], @@ -1524,5 +1524,33 @@ }, "additionalProperties": false } + }, + { + "name": "create_check_run", + "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter.", + "inputSchema": { + "type": "object", + "required": ["conclusion", "title", "summary"], + "properties": { + "conclusion": { + "type": "string", + "enum": ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"], + "description": "The final conclusion of the check run. Use \"success\" when the check passes, \"failure\" when issues are found that must be fixed, \"neutral\" for informational results with no required action." + }, + "title": { + "type": "string", + "description": "Short title summarizing the check result (e.g., \"3 issues found\" or \"All checks passed\"). Shown in the checks UI next to the check run name." + }, + "summary": { + "type": "string", + "description": "Markdown-formatted summary of the check result. Supports GitHub Flavored Markdown. Shown in the checks detail view. Maximum 65535 characters." + }, + "text": { + "type": "string", + "description": "Optional detailed Markdown content shown in the check run details. Use this for longer output such as full analysis reports, line-by-line findings, or remediation steps. Maximum 65535 characters." + } + }, + "additionalProperties": false + } } ] diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index e449a3c3..8a01b498 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -585,7 +585,7 @@ function parseOTLPCustomAttributes() { try { const parsed = JSON.parse(raw); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; - return /** @type {Record} */ (parsed); + return /** @type {Record} */ parsed; } catch { return null; } @@ -1522,17 +1522,63 @@ function getErrorMessage(errorEntry) { * @property {number | undefined} estimatedCostUsd * @property {string | undefined} stopReason * @property {string | undefined} resolvedModel + * @property {{input_tokens?: number, output_tokens?: number, cache_read_tokens?: number, cache_write_tokens?: number} | undefined} tokenUsage * @property {number} warningCount */ /** - * Read turns, estimated cost, and warning volume from agent-stdio.log. + * Normalize token usage counters from an engine result event usage block. + * + * @param {unknown} rawUsage + * @returns {{input_tokens?: number, output_tokens?: number, cache_read_tokens?: number, cache_write_tokens?: number} | undefined} + */ +function normalizeRuntimeTokenUsage(rawUsage) { + if (!rawUsage || typeof rawUsage !== "object" || Array.isArray(rawUsage)) { + return undefined; + } + + /** @type {{input_tokens?: number, output_tokens?: number, cache_read_tokens?: number, cache_write_tokens?: number, cache_read_input_tokens?: number, cache_creation_input_tokens?: number}} */ + const usage = rawUsage; + /** @type {{input_tokens?: number, output_tokens?: number, cache_read_tokens?: number, cache_write_tokens?: number}} */ + const normalized = {}; + if (typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) && usage.input_tokens >= 0) { + normalized.input_tokens = usage.input_tokens; + } + if (typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens) && usage.output_tokens >= 0) { + normalized.output_tokens = usage.output_tokens; + } + + const cacheReadTokens = + typeof usage.cache_read_tokens === "number" && Number.isFinite(usage.cache_read_tokens) && usage.cache_read_tokens >= 0 + ? usage.cache_read_tokens + : typeof usage.cache_read_input_tokens === "number" && Number.isFinite(usage.cache_read_input_tokens) && usage.cache_read_input_tokens >= 0 + ? usage.cache_read_input_tokens + : undefined; + if (typeof cacheReadTokens === "number") { + normalized.cache_read_tokens = cacheReadTokens; + } + + const cacheWriteTokens = + typeof usage.cache_write_tokens === "number" && Number.isFinite(usage.cache_write_tokens) && usage.cache_write_tokens >= 0 + ? usage.cache_write_tokens + : typeof usage.cache_creation_input_tokens === "number" && Number.isFinite(usage.cache_creation_input_tokens) && usage.cache_creation_input_tokens >= 0 + ? usage.cache_creation_input_tokens + : undefined; + if (typeof cacheWriteTokens === "number") { + normalized.cache_write_tokens = cacheWriteTokens; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +/** + * Read turns, estimated cost, token usage, and warning volume from agent-stdio.log. * * @returns {AgentRuntimeMetrics} */ function readAgentRuntimeMetrics() { /** @type {AgentRuntimeMetrics} */ - const metrics = { turns: undefined, estimatedCostUsd: undefined, stopReason: undefined, resolvedModel: undefined, warningCount: 0 }; + const metrics = { turns: undefined, estimatedCostUsd: undefined, stopReason: undefined, resolvedModel: undefined, tokenUsage: undefined, warningCount: 0 }; try { const content = fs.readFileSync(AGENT_STDIO_LOG_PATH, "utf8"); @@ -1565,6 +1611,10 @@ function readAgentRuntimeMetrics() { if (typeof parsed.stop_reason === "string" && parsed.stop_reason) { metrics.stopReason = parsed.stop_reason; } + const tokenUsage = normalizeRuntimeTokenUsage(parsed.usage); + if (tokenUsage) { + metrics.tokenUsage = tokenUsage; + } }; for (const rawLine of lines) { @@ -2016,7 +2066,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { // to avoid double-counting in backends that sum gen_ai.usage.* across all spans. // When no agent span is emitted the attributes fall through to the conclusion span // so a single query is still sufficient for observability. - const agentUsage = readJSONIfExists("/tmp/gh-aw/agent_usage.json") || {}; + const agentUsage = readJSONIfExists("/tmp/gh-aw/agent_usage.json") || runtimeMetrics.tokenUsage || {}; const usageAttrs = []; if (typeof agentUsage.input_tokens === "number" && agentUsage.input_tokens > 0) { usageAttrs.push(buildAttr("gen_ai.usage.input_tokens", agentUsage.input_tokens)); diff --git a/setup/md/manifest_protection_request_changes_review.md b/setup/md/manifest_protection_request_changes_review.md new file mode 100644 index 00000000..4e329c8e --- /dev/null +++ b/setup/md/manifest_protection_request_changes_review.md @@ -0,0 +1,5 @@ +Protected files were modified in this pull request and require manual scrutiny before merge. + +Please verify that each protected-file change is intentional, policy-compliant, and safe: + +- Protected files: {files} diff --git a/setup/md/manifest_protection_request_review.md b/setup/md/manifest_protection_request_review.md new file mode 100644 index 00000000..bab95ef0 --- /dev/null +++ b/setup/md/manifest_protection_request_review.md @@ -0,0 +1,5 @@ +> [!CAUTION] +> Protected files were modified in this change. +> This pull request is in `request_review` mode and requires explicit human scrutiny before merge. +> +> Protected files: {files} diff --git a/setup/md/threat_warning_request_changes_review.md b/setup/md/threat_warning_request_changes_review.md new file mode 100644 index 00000000..b7362935 --- /dev/null +++ b/setup/md/threat_warning_request_changes_review.md @@ -0,0 +1,6 @@ +Threat detection produced a warning for this pull request output. + +These changes need to be scrutinized before merge and only merged after a careful manual review. + +- Detection reason: `{detectionReason}` +- Review workflow run logs: {runUrl}