From 7c6aaa6b625a16ece195159b86487f60d938969c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:11:47 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.83.5 --- setup/js/ai_credits_context.cjs | 136 ++++++++++++- setup/js/apply_samples.cjs | 60 +++++- setup/js/codex_harness.cjs | 118 ++++++++++- setup/js/collect_ndjson_output.cjs | 18 ++ setup/js/copilot_harness.cjs | 41 ++-- setup/js/create_pr_review_comment.cjs | 11 + setup/js/create_pull_request.cjs | 118 ++++++----- setup/js/data_schema_normalizer.cjs | 170 ++++++++++++++++ setup/js/detect_agent_errors.cjs | 72 ++++++- setup/js/firewall_blocked_domains.cjs | 14 +- setup/js/git_auth_helpers.cjs | 33 +++ setup/js/handle_agent_failure.cjs | 235 +++++++++++++++++++++- setup/js/log_parser_shared.cjs | 9 + setup/js/mcp_scripts_validation.cjs | 5 + setup/js/models.json | 9 + setup/js/mount_mcp_as_cli.cjs | 57 +++++- setup/js/package.json | 4 +- setup/js/parse_firewall_logs.cjs | 50 ++++- setup/js/pr_review_buffer.cjs | 44 +++- setup/js/process_runner.cjs | 35 +++- setup/js/push_to_pull_request_branch.cjs | 29 +-- setup/js/render_template.cjs | 6 +- setup/js/safe_output_summary.cjs | 9 + setup/js/safe_output_type_validator.cjs | 79 +++++++- setup/js/safe_outputs_handlers.cjs | 25 +++ setup/js/safe_outputs_tools.json | 71 ++++--- setup/js/start_mcp_gateway.cjs | 19 ++ setup/js/submit_pr_review.cjs | 10 + setup/md/agent_failure_comment.md | 2 +- setup/md/agent_failure_issue.md | 2 +- setup/md/missing_model_pricing.md | 29 +++ setup/setup.sh | 1 + setup/sh/convert_gateway_config_gemini.sh | 10 +- 33 files changed, 1350 insertions(+), 181 deletions(-) create mode 100644 setup/js/data_schema_normalizer.cjs create mode 100644 setup/md/missing_model_pricing.md diff --git a/setup/js/ai_credits_context.cjs b/setup/js/ai_credits_context.cjs index 5edba535..327b51a7 100644 --- a/setup/js/ai_credits_context.cjs +++ b/setup/js/ai_credits_context.cjs @@ -71,6 +71,14 @@ function isTrueLike(value) { return value === true || value === "true" || value === 1 || value === "1"; } +/** + * @param {unknown} value + * @returns {string} + */ +function sanitizeModelName(value) { + return typeof value === "string" ? value.replace(/\r?\n|\r/g, " ").trim() : ""; +} + /** * @param {string} [auditJsonlPathOverride] * @returns {string} @@ -95,6 +103,44 @@ function resolveFirewallAuditLogPath(auditJsonlPathOverride) { return path.join(candidateBases[0], "log.jsonl"); } +/** + * @param {string} [auditJsonlPathOverride] + * @returns {string[]} + */ +function resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride) { + if (auditJsonlPathOverride) return [auditJsonlPathOverride]; + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + const roots = []; + if (agentOutputFile) { + roots.push(path.dirname(agentOutputFile)); + } + + /** @type {string[]} */ + const candidates = []; + const seen = new Set(); + const addCandidate = candidate => { + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + candidates.push(candidate); + }; + + for (const root of roots) { + addCandidate(path.join(root, "sandbox", "firewall", "logs", "api-proxy-logs", "event-logs.jsonl")); + addCandidate(path.join(root, "sandbox", "firewall", "logs", "api-proxy-logs", "events.jsonl")); + addCandidate(path.join(root, "sandbox", "firewall", "audit", "api-proxy-logs", "event-logs.jsonl")); + addCandidate(path.join(root, "sandbox", "firewall", "audit", "api-proxy-logs", "events.jsonl")); + } + + addCandidate("/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/event-logs.jsonl"); + addCandidate("/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/events.jsonl"); + addCandidate("/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/event-logs.jsonl"); + addCandidate("/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/events.jsonl"); + addCandidate("/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/event-logs.jsonl"); + addCandidate("/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/events.jsonl"); + addCandidate(resolveFirewallAuditLogPath()); + return candidates; +} + /** * Depth-first traversal of a nested object, calling visitor for each [key, value] pair. * Traversal stops early if visitor returns true. @@ -193,6 +239,48 @@ function iterateAuditEntries(auditJsonlPathOverride, defaultValue, contentGuard, } } +/** + * Iterates one or more JSONL files, accumulating parsed entries across every existing file. + * Missing, unreadable, or malformed files/lines are ignored. + * + * @template T + * @param {string[]} filePaths + * @param {T} defaultValue + * @param {((content: string) => boolean) | null} contentGuard + * @param {(acc: T, entry: unknown) => T | undefined} accumulate + * @param {(acc: T) => boolean} [shouldStop] + * @returns {T} + */ +function iterateJSONLFiles(filePaths, defaultValue, contentGuard, accumulate, shouldStop) { + let result = defaultValue; + try { + for (const filePath of filePaths) { + try { + if (!fs.existsSync(filePath)) continue; + const content = fs.readFileSync(filePath, "utf8"); + if (!content.trim()) continue; + if (contentGuard && !contentGuard(content)) continue; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed[0] !== "{") continue; + try { + const nextResult = accumulate(result, JSON.parse(trimmed)); + if (nextResult !== undefined) result = nextResult; + if (shouldStop && shouldStop(result)) return result; + } catch { + // ignore malformed lines + } + } + } catch { + // ignore unreadable files and continue to the next candidate + } + } + return result; + } catch { + return defaultValue; + } +} + /** * @param {string} [auditJsonlPathOverride] * @returns {string} @@ -288,11 +376,52 @@ function parseUnknownModelAICreditsFromAuditEntry(entry) { * @returns {boolean} */ function parseUnknownModelAICreditsFromAuditLog(auditJsonlPathOverride) { - return iterateAuditEntries( - auditJsonlPathOverride, + return iterateJSONLFiles( + resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride), false, content => content.includes(UNKNOWN_MODEL_AI_CREDITS_TYPE), - (acc, entry) => acc || parseUnknownModelAICreditsFromAuditEntry(entry) + (acc, entry) => acc || parseUnknownModelAICreditsFromAuditEntry(entry), + acc => acc + ); +} + +/** + * Detects `unknown_model_ai_credits` from the firewall event/audit JSONL logs and extracts the model name. + * Structured entries emitted by the AWF API proxy carry both the error type and the model name, e.g.: + * { "type": "unknown_model_ai_credits", "model": "claude-opus-5" } + * + * @param {string} [auditJsonlPathOverride] + * @returns {{ detected: boolean, modelName: string }} + */ +function parseUnknownModelAICreditsAndModelFromAuditLog(auditJsonlPathOverride) { + /** @type {{ detected: boolean, modelName: string }} */ + const initial = { detected: false, modelName: "" }; + return iterateJSONLFiles( + resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride), + initial, + content => content.includes(UNKNOWN_MODEL_AI_CREDITS_TYPE), + /** + * @param {{ detected: boolean, modelName: string }} acc + * @param {unknown} entry + * @returns {{ detected: boolean, modelName: string } | undefined} + */ + (acc, entry) => { + if (acc.detected && acc.modelName) return acc; // fully resolved, skip remaining entries + if (!parseUnknownModelAICreditsFromAuditEntry(entry)) return undefined; // not a matching entry + let modelName = acc.modelName; + if (!modelName) { + traverseObjectTree(entry, (key, value) => { + const sanitized = sanitizeModelName(value); + if (key === "model" && sanitized) { + modelName = sanitized; + return true; + } + return false; + }); + } + return { detected: true, modelName }; + }, + acc => acc.detected && !!acc.modelName ); } @@ -422,5 +551,6 @@ module.exports = { parseAICreditsErrorInfoFromAuditLog, parseMaxAICreditsExceededFromAuditLog, parseUnknownModelAICreditsFromAuditLog, + parseUnknownModelAICreditsAndModelFromAuditLog, resolveAICreditsFailureState, }; diff --git a/setup/js/apply_samples.cjs b/setup/js/apply_samples.cjs index 10d2a91b..8481fc2e 100644 --- a/setup/js/apply_samples.cjs +++ b/setup/js/apply_samples.cjs @@ -248,16 +248,30 @@ async function derivePrHeadRef(entry) { if (ref) return ref; } - // 3. Explicit pull_request_number on the sample arguments. - const argNumber = Number(entry.arguments.pull_request_number); - if (Number.isFinite(argNumber) && argNumber > 0) { - const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber }); + // 3. PR number from sample arguments, workflow_dispatch inputs, or config target. + const pullNumber = + toPositivePullRequestNumber(entry.arguments.pull_request_number) || + toPositivePullRequestNumber(payload?.inputs?.pull_request_number) || + toPositivePullRequestNumber(payload?.client_payload?.pull_request_number) || + readConfiguredTargetPullRequestNumber(entry.tool); + if (pullNumber) { + const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber }); if (ref) return ref; } return null; } +/** + * Convert unknown value to a positive pull request number, or null. + * @param {any} value + * @returns {number|null} + */ +function toPositivePullRequestNumber(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : null; +} + /** * Read the configured `target-repo` for a given safe-output tool from the * safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty @@ -291,6 +305,44 @@ function readConfiguredTargetRepo(tool) { return ""; } +/** + * Read configured `target` for a safe-output tool and coerce it into a PR number. + * Supports plain numeric values and `${ENV_VAR}` placeholders. + * @param {string} tool + * @returns {number|null} + */ +function readConfiguredTargetPullRequestNumber(tool) { + const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH; + if (!configPath || !configPath.trim()) { + return null; + } + + const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : ""; + + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw); + const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {}; + const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null; + const target = toolConfig && typeof toolConfig === "object" ? toolConfig.target : null; + + if (typeof target === "number") { + return toPositivePullRequestNumber(target); + } + if (typeof target === "string") { + const trimmed = target.trim(); + const envMatch = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed); + if (envMatch) { + return toPositivePullRequestNumber(process.env[envMatch[1]]); + } + return toPositivePullRequestNumber(trimmed); + } + } catch (err) { + core.debug(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`); + } + return null; +} + /** * Resolve the on-disk working directory in which a sample's patch should be * staged (branch created + patch committed). diff --git a/setup/js/codex_harness.cjs b/setup/js/codex_harness.cjs index 54e737df..11aa5fb5 100644 --- a/setup/js/codex_harness.cjs +++ b/setup/js/codex_harness.cjs @@ -35,7 +35,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const fs = require("fs"); -const { runProcess, formatDuration, sleep } = require("./process_runner.cjs"); +const { runProcess, formatDuration, sleep, MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, resolvePostResultWatchdogIdleTimeoutMs } = require("./process_runner.cjs"); const { AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, @@ -84,6 +84,86 @@ const MISSING_API_KEY_PATTERN = /Missing environment variable:\s*`?(?:CODEX_API_ // These are transient infrastructure failures that may resolve on retry. const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 Internal Server Error|503 Service Unavailable/i; +// Post-result watchdog: once the agent writes a terminal safe-output the harness +// arms a watchdog timer and kills the Codex process if it is still running after +// POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS of inactivity. This prevents the step from +// hitting its hard timeout when Codex hangs on exit after completing its work. +// Constants and resolvePostResultWatchdogIdleTimeoutMs are imported from process_runner.cjs. +const POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = resolvePostResultWatchdogIdleTimeoutMs(); + +// Types that are NOT terminal safe-outputs (infrastructure/diagnostic signals). +// A terminal safe-output is any entry whose type is NOT in this set, plus "noop". +const SAFE_OUTPUT_NON_TERMINAL_TYPES = new Set(["missing_tool", "report_incomplete"]); + +/** + * Return the current byte size of the safe-outputs file, or 0 if the file does not + * yet exist. Used as a per-attempt baseline so the watchdog only arms on output + * appended by the current attempt, not a record left by an earlier retry. + * @param {string} safeOutputsPath + * @returns {number} + */ +function getSafeOutputsByteOffset(safeOutputsPath) { + try { + return fs.statSync(safeOutputsPath).size; + } catch { + return 0; + } +} + +/** + * Read only the content of the safe-outputs JSONL file appended after byteOffset and + * return true if at least one terminal safe-output entry is present in that new content. + * A terminal safe-output is either a "noop" (nothing to do) or a non-diagnostic task + * result (e.g. add-labels, hide-comment). + * + * Using a per-attempt byte offset prevents the watchdog from arming on output produced + * by an earlier retry: if attempt N wrote a terminal record and exited non-zero before + * the watchdog polled, attempt N+1 would otherwise arm immediately and be killed even + * though it produced nothing useful. + * + * @param {string} safeOutputsPath + * @param {number} byteOffset - byte position in the file at the start of the current attempt + * @param {{ logger?: (msg: string) => void }=} options + * @returns {boolean} + */ +function hasTerminalSafeOutput(safeOutputsPath, byteOffset, options) { + const logger = options && options.logger ? options.logger : () => {}; + if (!safeOutputsPath) return false; + let content = ""; + try { + const fd = fs.openSync(safeOutputsPath, "r"); + try { + const stats = fs.fstatSync(fd); + const fileSize = stats.size; + if (fileSize <= byteOffset) return false; + const length = fileSize - byteOffset; + const buf = Buffer.allocUnsafe(length); + fs.readSync(fd, buf, 0, length, byteOffset); + content = buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed.type !== "string") continue; + const type = parsed.type; + if (type === "noop" || !SAFE_OUTPUT_NON_TERMINAL_TYPES.has(type)) { + logger(`hasTerminalSafeOutput: terminal entry found in ${safeOutputsPath}: type=${type}`); + return true; + } + } catch { + // Ignore malformed lines. + } + } + return false; +} + /** * Emit a timestamped diagnostic log line to stderr. * All driver messages are prefixed with "[codex-harness]" so they are easy to @@ -509,7 +589,24 @@ async function main() { } } - const result = await runProcess({ command, args: resolvedArgs, attempt, log, logArgs: safeArgs, env: codexEnv }); + // Track the file size before this attempt so the watchdog only arms on output + // written by this attempt, not by a previous retry. + const safeOutputsByteOffset = safeOutputsPath ? getSafeOutputsByteOffset(safeOutputsPath) : 0; + + const result = await runProcess({ + command, + args: resolvedArgs, + attempt, + log, + logArgs: safeArgs, + env: codexEnv, + postResultWatchdog: safeOutputsPath + ? { + shouldArm: () => hasTerminalSafeOutput(safeOutputsPath, safeOutputsByteOffset, { logger: log }), + inactivityTimeoutMs: POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + } + : undefined, + }); lastExitCode = result.exitCode; // Success — stop retrying @@ -519,6 +616,17 @@ async function main() { break; } + // When the post-result watchdog fired (SIGTERM sent to a hanging Codex process) and the + // safe-outputs file contains a terminal result written during this attempt, treat the run + // as a success. The agent completed its work and wrote its output — the hang on exit is + // a cosmetic failure, not a task failure. Check this before logging "attempt failed" so + // the log stream does not contradict itself for what is ultimately a successful run. + if (result.watchdogFired && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath, safeOutputsByteOffset, { logger: log })) { + log(`attempt ${attempt + 1}: post-result watchdog fired after terminal safe-output was emitted — treating as success (late-activity exit suppressed)`); + lastExitCode = 0; + break; + } + const isRateLimit = isRateLimitError(result.output); const isTokenPerMinuteRateLimit = isTokenPerMinuteRateLimitError(result.output); const isAuthenticationFailed = isAuthenticationFailedError(result.output); @@ -530,6 +638,7 @@ async function main() { log( `attempt ${attempt + 1} failed:` + ` exitCode=${result.exitCode}` + + ` watchdogFired=${result.watchdogFired}` + ` isRateLimitError=${isRateLimit}` + ` isTokenPerMinuteRateLimitError=${isTokenPerMinuteRateLimit}` + ` isAuthenticationFailedError=${isAuthenticationFailed}` + @@ -662,6 +771,11 @@ if (typeof module !== "undefined" && module.exports) { applyModelFallback, injectModelFlagAfterExec, getCodexModelEnvVar, + resolvePostResultWatchdogIdleTimeoutMs, + POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, }; } diff --git a/setup/js/collect_ndjson_output.cjs b/setup/js/collect_ndjson_output.cjs index e04f92df..0f01abf5 100644 --- a/setup/js/collect_ndjson_output.cjs +++ b/setup/js/collect_ndjson_output.cjs @@ -181,6 +181,22 @@ async function main() { return; } if (!fs.existsSync(outputFile)) { + // Before treating a missing outputs file as a graceful no-op, check whether + // the safeoutputs MCP gateway reported 0 registered tools during setup. + // When that flag exists the agent could not emit any safe outputs because + // every safeoutputs call failed with "unknown tool" — this is a gateway + // infrastructure failure, not an intentional no-op, and must surface as an + // error rather than a silent green run. + const runnerTemp = process.env.RUNNER_TEMP || "/home/runner/work/_temp"; + const gatewayEmptyFlagPath = `${runnerTemp}/gh-aw/safeoutputs/gateway_empty.flag`; + if (fs.existsSync(gatewayEmptyFlagPath)) { + core.setFailed( + `safeoutputs MCP gateway registered 0 tools during setup; the agent could not emit any safe outputs. ` + + `This is a gateway infrastructure failure, not a normal no-op. ` + + `Check the MCP gateway startup logs for ECONNRESET errors or delayed backend registration and re-run the workflow.` + ); + return; + } core.info(`Output file does not exist: ${outputFile} — no safe-output items were emitted; treating as empty collection (graceful no-op)`); const emptyOutput = { items: [], errors: [] }; const emptyOutputJson = JSON.stringify(emptyOutput); @@ -343,6 +359,8 @@ async function main() { allowedAliases: allowedMentions, maxBotMentions, normalizeIssueClosingKeywords, + dataEnabled: typeConfig !== null && typeof typeConfig === "object" && typeConfig.data_enabled === true, + dataSchema: typeConfig !== null && typeof typeConfig === "object" ? typeConfig.data_schema : undefined, }); if (!validationResult.isValid) { if (validationResult.error) { diff --git a/setup/js/copilot_harness.cjs b/setup/js/copilot_harness.cjs index 9c131b4e..100557ed 100644 --- a/setup/js/copilot_harness.cjs +++ b/setup/js/copilot_harness.cjs @@ -45,7 +45,16 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const fs = require("fs"); const crypto = require("crypto"); const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); -const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs"); +const { + runProcess, + formatDuration, + sleep, + isCopilotSDKEnabled, + buildCopilotSDKEnv, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + resolvePostResultWatchdogIdleTimeoutMs, +} = require("./process_runner.cjs"); const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs"); const { resolveRetryConfig: resolveSharedRetryConfig } = require("./harness_retry_config.cjs"); const { @@ -82,8 +91,6 @@ const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB"; const MAX_ENV_VAR_PREVIEW_LENGTH = 120; const OUTPUT_TAIL_MAX_CHARS = 600; const OUTPUT_TAIL_MAX_LINES = 12; -const MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS = 50; -const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 20 * 1000; // Default token count threshold above which a 0-turn failure is classified as "long_run_exit" // rather than the generic "partial_execution". Corresponds to ~30+ minutes of Copilot // CLI work where the wrapper exits non-zero after the agent has completed substantial work. @@ -97,13 +104,6 @@ function resolveLongRunTokenThreshold(env = process.env) { return configured; } const LONG_RUN_TOKEN_THRESHOLD = resolveLongRunTokenThreshold(); -function resolvePostResultWatchdogIdleTimeoutMs(env = process.env) { - const configuredTimeoutMs = Number(env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS); - if (!Number.isFinite(configuredTimeoutMs) || configuredTimeoutMs <= 0) { - return DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS; - } - return Math.max(MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, configuredTimeoutMs); -} const POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = resolvePostResultWatchdogIdleTimeoutMs(); const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md"; // Pattern to detect transient CAPIError 400 in copilot output @@ -294,6 +294,15 @@ function computeStartupRetryEligible(eventName) { return eventName === "schedule" || eventName === "push"; } +/** + * Returns true when a failed attempt qualifies for the startup no-output retry budget. + * @param {{exitCode: number, hasOutput: boolean}} result + * @returns {boolean} + */ +function isStartupNoOutputRetryCandidate(result) { + return !result.hasOutput && result.exitCode === 2; +} + /** * Read AWF config written by the compiler before the agent runs. * @returns {any|null} @@ -1258,7 +1267,11 @@ async function main() { // (watchdogFired=true), as well as any other partial_execution failure that occurs // after the primary task output was already produced. Retrying would reproduce the // same pattern and exhaust the retry budget without ever posting a final safe-output. - if ((failureClass === "partial_execution" || failureClass === "long_run_exit") && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { + // The no_output + watchdogFired case is also handled here: the post-result watchdog is + // only armed after hasTerminalSafeOutput is true, so watchdogFired on a no-stdio-output + // run means the agent completed its task (wrote safe-output) but produced no console + // output before the watchdog terminated the idle process. + if ((failureClass === "partial_execution" || failureClass === "long_run_exit" || (failureClass === "no_output" && result.watchdogFired)) && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { const reason = result.watchdogFired ? "post-result watchdog fired after terminal safe-output was emitted" : "partial execution after terminal safe-output was already produced"; log(`attempt ${attempt + 1}: ${reason} — treating as success (late-activity exit suppressed)`); lastExitCode = 0; @@ -1379,7 +1392,7 @@ async function main() { // Scheduled and push-triggered runs: retry once on exit code 2 even when no output was // produced. This specifically targets transient Copilot API outages at startup where // there is no partial session state to continue from (Turns=0 driver-handoff failure). - if (isStartupRetryEligible && result.exitCode === 2 && !result.hasOutput && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { + if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { scheduledExit2Retries += 1; scheduledExit2RetryAttempted = true; useContinueOnRetry = false; @@ -1387,7 +1400,7 @@ async function main() { log(`attempt ${attempt + 1}: ${triggerLabel} startup interruption (exit code 2, no output — driver-handoff Turns=0)` + ` — retrying once as fresh run (startupRetry=${scheduledExit2Retries}/${MAX_SCHEDULED_EXIT2_RETRIES})`); continue; } - if (isStartupRetryEligible && result.exitCode === 2 && !result.hasOutput && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt >= maxRetries) { + if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt >= maxRetries) { log(`attempt ${attempt + 1}: startup interruption detected (driver-handoff Turns=0) but retry budget exhausted — no attempts remain`); } @@ -1416,7 +1429,7 @@ async function main() { break; } - if (isStartupRetryEligible && lastExitCode === 2 && scheduledExit2RetryAttempted && !lastHasOutput) { + if (isStartupRetryEligible && scheduledExit2RetryAttempted && isStartupNoOutputRetryCandidate({ exitCode: lastExitCode, hasOutput: lastHasOutput })) { const triggerLabel = isScheduledRun ? "scheduled" : "push"; emitInfrastructureIncomplete( `Copilot API interruption (exit code 2) persisted after automatic retry in ${triggerLabel} workflow run. ` + "This is the Turns=0 driver-handoff failure signature. Check the agent-stdio.log for startup diagnostics." diff --git a/setup/js/create_pr_review_comment.cjs b/setup/js/create_pr_review_comment.cjs index e33a5f43..41915f95 100644 --- a/setup/js/create_pr_review_comment.cjs +++ b/setup/js/create_pr_review_comment.cjs @@ -42,6 +42,7 @@ async function main(config = {}) { const requiredTitlePrefix = config.required_title_prefix || ""; if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`); if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); + let allowedMentionAliases = []; if (Array.isArray(config.allowedMentionAliases)) { allowedMentionAliases = config.allowedMentionAliases; @@ -73,6 +74,16 @@ async function main(config = {}) { logStagedPreviewInfo("PR review comments will be previewed without being submitted"); } + const pinnedCommitId = typeof config.commit_id === "string" ? config.commit_id.trim() : ""; + if (pinnedCommitId) { + core.info(`create_pull_request_review_comment: commit-id pinned to ${pinnedCommitId}`); + if (registry && typeof registry.setDefaultPinnedCommitId === "function") { + registry.setDefaultPinnedCommitId(pinnedCommitId); + } else if (legacyBuffer && typeof legacyBuffer.setPinnedCommitId === "function") { + legacyBuffer.setPinnedCommitId(pinnedCommitId); + } + } + // Extract triggering context for footer generation const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); diff --git a/setup/js/create_pull_request.cjs b/setup/js/create_pull_request.cjs index 532df3b1..373245a5 100644 --- a/setup/js/create_pull_request.cjs +++ b/setup/js/create_pull_request.cjs @@ -30,7 +30,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { renderTemplateFromFile, renderFilesList, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); -const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); +const { withGitHubHostToken } = require("./git_auth_helpers.cjs"); const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); @@ -567,27 +567,18 @@ function enforcePullRequestLimits(patchContent, maxFiles = MAX_FILES) { * @param {string} [options.repo] - Repository name for the deleteRef call. * @param {string} [options.remoteTarget] - Remote name or URL used for remote branch existence checks. * @param {string} [options.remoteToken] - Optional token used for authenticated remote branch checks. + * @param {string} [options.cwd] - Optional working directory for git operations; scopes git config overrides to the correct checkout. * @returns {Promise} The (possibly renamed) branch name to use going forward. */ async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) { + const cwd = options.cwd; let remoteBranchExists = false; try { const remoteTarget = options.remoteTarget || "origin"; - const checkRemoteBranch = async () => exec.getExecOutput("git", ["ls-remote", "--heads", remoteTarget, branchName]); + const checkRemoteBranch = async () => exec.getExecOutput("git", ["ls-remote", "--heads", remoteTarget, branchName], cwd ? { cwd } : {}); let checkResult; if (options.remoteToken) { - const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); - let previousExtraheaders = []; - let overrideApplied = false; - try { - previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, options.remoteToken); - overrideApplied = true; - checkResult = await checkRemoteBranch(); - } finally { - if (overrideApplied) { - await restorePersistedExtraheader(githubServerUrl, previousExtraheaders); - } - } + checkResult = await withGitHubHostToken(options.remoteToken, checkRemoteBranch, cwd); } else { checkResult = await checkRemoteBranch(); } @@ -659,7 +650,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio const oldBranch = branchName; const renamedBranch = `${branchName}-${extraHex}`; // Rename local branch - await exec.exec("git", ["branch", "-m", oldBranch, renamedBranch]); + await exec.exec("git", ["branch", "-m", oldBranch, renamedBranch], cwd ? { cwd } : {}); core.info(`Renamed branch to ${renamedBranch}`); return renamedBranch; } @@ -1638,7 +1629,8 @@ async function main(config = {}) { // fallback issue can include a compare URL. Genuine push failures are handled in // the catch block below. { - try { + const forkCwd = process.cwd(); + const runBundlePush = async () => { branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient: pushGithubClient, @@ -1646,6 +1638,7 @@ async function main(config = {}) { repo: pushRepoParts.repo, remoteTarget: pushRemoteUrl || "origin", remoteToken: headGitHubToken, + cwd: forkCwd, }); await pushSignedCommits({ @@ -1654,7 +1647,7 @@ async function main(config = {}) { repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), + cwd: forkCwd, pushRemoteUrl, pushToken: headGitHubToken, signedCommits, @@ -1662,6 +1655,9 @@ async function main(config = {}) { currentRepo: itemRepo, validationConfig: config, }); + }; + try { + await runBundlePush(); core.info("Changes pushed to branch (from bundle)"); // Count new commits on PR branch relative to base @@ -1683,20 +1679,22 @@ async function main(config = {}) { core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); try { await rewriteBundleBranchAsSingleCommit(baseBranch, exec); - await pushSignedCommits({ - githubClient: pushGithubClient, - owner: pushRepoParts.owner, - repo: pushRepoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - pushRemoteUrl, - pushToken: headGitHubToken, - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - validationConfig: config, - }); + const runRetryPush = async () => + pushSignedCommits({ + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: forkCwd, + pushRemoteUrl, + pushToken: headGitHubToken, + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + validationConfig: config, + }); + await runRetryPush(); core.info("Changes pushed to branch after bundle rewrite retry"); try { @@ -2025,7 +2023,8 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead // fallback issue can include a compare URL. Genuine push failures are handled in // the catch block below. { - try { + const forkCwd = process.cwd(); + const runPatchPush = async () => { branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient: pushGithubClient, @@ -2033,6 +2032,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead repo: pushRepoParts.repo, remoteTarget: pushRemoteUrl || "origin", remoteToken: headGitHubToken, + cwd: forkCwd, }); await pushSignedCommits({ @@ -2041,7 +2041,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), + cwd: forkCwd, pushRemoteUrl, pushToken: headGitHubToken, signedCommits, @@ -2049,6 +2049,9 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead currentRepo: itemRepo, validationConfig: config, }); + }; + try { + await runPatchPush(); core.info("Changes pushed to branch"); // Count new commits on PR branch relative to base, used to restrict @@ -2196,29 +2199,34 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { - recreateRef, - githubClient: pushGithubClient, - owner: pushRepoParts.owner, - repo: pushRepoParts.repo, - remoteTarget: pushRemoteUrl || "origin", - remoteToken: headGitHubToken, - }); + const forkCwd = process.cwd(); + const runEmptyPush = async () => { + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { + recreateRef, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + remoteTarget: pushRemoteUrl || "origin", + remoteToken: headGitHubToken, + cwd: forkCwd, + }); - await pushSignedCommits({ - githubClient: pushGithubClient, - owner: pushRepoParts.owner, - repo: pushRepoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - pushRemoteUrl, - pushToken: headGitHubToken, - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - validationConfig: config, - }); + await pushSignedCommits({ + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: forkCwd, + pushRemoteUrl, + pushToken: headGitHubToken, + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + validationConfig: config, + }); + }; + await runEmptyPush(); core.info("Empty branch pushed successfully"); // Count new commits (will be 1 from the Initialize commit) diff --git a/setup/js/data_schema_normalizer.cjs b/setup/js/data_schema_normalizer.cjs new file mode 100644 index 00000000..4a5891b6 --- /dev/null +++ b/setup/js/data_schema_normalizer.cjs @@ -0,0 +1,170 @@ +// @ts-check + +const SUPPORTED_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean"]); +const ALLOWED_KEYS = new Set(["type", "description", "properties", "required", "items", "enum", "additionalProperties", "minLength", "maxLength", "minimum", "maximum", "pattern"]); + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasSchemaKeyword(value) { + return Object.keys(value).some(key => ALLOWED_KEYS.has(key)); +} + +/** + * @param {any} raw + * @param {string} path + * @param {boolean} allowShorthand + * @returns {Record} + */ +function simplifySchemaNode(raw, path, allowShorthand) { + if (typeof raw === "string") { + if (!allowShorthand) { + throw new Error(`${path}: string shorthand is not allowed here`); + } + if (!SUPPORTED_TYPES.has(raw)) { + throw new Error(`${path}: unsupported type "${raw}"`); + } + return { type: raw }; + } + + if (!isPlainObject(raw)) { + throw new Error(`${path}: expected an object schema`); + } + + let node = raw; + let explicit = hasSchemaKeyword(node); + if (!explicit && allowShorthand) { + explicit = true; + node = { type: "object", properties: node }; + } + if (!explicit) { + throw new Error(`${path}: expected JSON schema keywords or shorthand properties`); + } + + for (const key of Object.keys(node)) { + if (!ALLOWED_KEYS.has(key)) { + throw new Error(`${path}: unsupported keyword "${key}"`); + } + } + + const result = {}; + let typeName = typeof node.type === "string" ? node.type : ""; + if (!typeName) { + if (node.properties !== undefined || node.required !== undefined || node.additionalProperties !== undefined) { + typeName = "object"; + } else if (node.items !== undefined) { + typeName = "array"; + } + } + if (typeName) { + if (!SUPPORTED_TYPES.has(typeName)) { + throw new Error(`${path}.type: unsupported type "${typeName}"`); + } + result.type = typeName; + } + + if (node.description !== undefined) { + if (typeof node.description !== "string") { + throw new Error(`${path}.description: must be a string`); + } + result.description = node.description; + } + + if (node.enum !== undefined) { + if (!Array.isArray(node.enum) || node.enum.length === 0) { + throw new Error(`${path}.enum: must be a non-empty array`); + } + for (let i = 0; i < node.enum.length; i++) { + const enumItem = node.enum[i]; + if (typeof enumItem !== "string" && typeof enumItem !== "number" && typeof enumItem !== "boolean") { + throw new Error(`${path}.enum[${i}]: must be a scalar value`); + } + } + result.enum = node.enum; + } + + if (typeName === "object") { + if (!isPlainObject(node.properties)) { + throw new Error(`${path}.properties: is required for object schemas`); + } + const normalizedProperties = {}; + for (const [key, value] of Object.entries(node.properties)) { + normalizedProperties[key] = simplifySchemaNode(value, `${path}.properties.${key}`, true); + } + result.properties = normalizedProperties; + + const requiredSet = new Set(Object.keys(normalizedProperties)); + if (node.required !== undefined) { + if (!Array.isArray(node.required)) { + throw new Error(`${path}.required: must be an array of strings`); + } + for (let i = 0; i < node.required.length; i++) { + const requiredName = node.required[i]; + if (typeof requiredName !== "string" || requiredName.trim().length === 0) { + throw new Error(`${path}.required[${i}]: must be a non-empty string`); + } + if (!Object.prototype.hasOwnProperty.call(normalizedProperties, requiredName)) { + throw new Error(`${path}.required[${i}]: unknown property "${requiredName}"`); + } + requiredSet.add(requiredName); + } + } + result.required = [...requiredSet].sort(); + + if (node.additionalProperties !== undefined) { + if (typeof node.additionalProperties !== "boolean") { + throw new Error(`${path}.additionalProperties: must be boolean`); + } + if (node.additionalProperties) { + throw new Error(`${path}.additionalProperties: must be false for OpenAI Codex structured outputs compatibility`); + } + } + result.additionalProperties = false; + } else if (typeName === "array") { + if (node.items === undefined) { + throw new Error(`${path}.items: is required for array schemas`); + } + result.items = simplifySchemaNode(node.items, `${path}.items`, true); + } else if (typeName === "string") { + if (node.minLength !== undefined) result.minLength = node.minLength; + if (node.maxLength !== undefined) result.maxLength = node.maxLength; + if (node.pattern !== undefined) result.pattern = node.pattern; + } else if (typeName === "number" || typeName === "integer") { + if (node.minimum !== undefined) result.minimum = node.minimum; + if (node.maximum !== undefined) result.maximum = node.maximum; + } + + return result; +} + +/** + * @param {any} rawSchema + * @param {string} path + * @returns {Record} + */ +function resolveDataSchema(rawSchema, path) { + if (isPlainObject(rawSchema)) { + const normalized = simplifySchemaNode(rawSchema, path, true); + if (normalized.type !== "object") { + throw new Error(`${path}: must resolve to an object schema`); + } + return normalized; + } + if (typeof rawSchema === "string") { + const parsed = JSON.parse(rawSchema); + if (!isPlainObject(parsed)) { + throw new Error(`${path}: string JSON must decode to an object schema`); + } + const normalized = simplifySchemaNode(parsed, path, true); + if (normalized.type !== "object") { + throw new Error(`${path}: must resolve to an object schema`); + } + return normalized; + } + throw new Error(`${path}: must be an object schema or JSON string`); +} + +module.exports = { + resolveDataSchema, +}; diff --git a/setup/js/detect_agent_errors.cjs b/setup/js/detect_agent_errors.cjs index 0394d1e9..3edb1395 100644 --- a/setup/js/detect_agent_errors.cjs +++ b/setup/js/detect_agent_errors.cjs @@ -1,10 +1,11 @@ // @ts-check /** - * Detect agent engine errors in the agent stdio log. + * Detect agent engine errors in the agent stdio log and AWF firewall audit log. * - * Scans the agent stdio log for known error patterns and sets GitHub Actions - * output variables for each detected error class: + * Scans the agent stdio log for known error patterns and the AWF firewall audit + * JSONL log for structured error events, then sets GitHub Actions output variables + * for each detected error class: * * - inference_access_error: The COPILOT_GITHUB_TOKEN does not have valid * access to inference (e.g., "Access denied by policy settings"). @@ -28,6 +29,11 @@ * fully exhausted (e.g., "CAPIError: 429 Maximum LLM invocations exceeded (N/N)" * or `"type":"max_runs_exceeded"`). This is more specific than generic * CAPI quota exhaustion and takes precedence in step outputs. + * - missing_model_pricing_error / missing_model_pricing_model_name: The AWF API + * proxy rejected a request because the model has no AI credits pricing entry. + * Detected from the agent stdio log (text pattern) and the AWF firewall audit + * JSONL log (`unknown_model_ai_credits` event type). Both sources are checked + * and their results merged. * This replaces the individual bash scripts (detect_inference_access_error.sh, * detect_mcp_policy_error.sh) with a single JavaScript step. * @@ -39,6 +45,7 @@ const fs = require("fs"); const { MAX_RUNS_EXCEEDED_PATTERNS, isMaxRunsExceededError } = require("./harness_retry_guard.cjs"); +const { parseUnknownModelAICreditsAndModelFromAuditLog } = require("./ai_credits_context.cjs"); const LOG_FILE = "/tmp/gh-aw/agent-stdio.log"; @@ -80,6 +87,13 @@ const MODEL_NOT_SUPPORTED_PATTERN = const HTTP_400_RESPONSE_ERROR_PATTERN = /(?:Response status code does not indicate success:\s*400(?:\s*\(Bad Request\))?|400[^\n]*no model endpoints available given user constraints|400[^\n]*stream_options:\s*Extra inputs are not permitted)/i; +// Pattern: AWF API proxy rejects a request because the model has no AI credits pricing configured +// and no default fallback pricing is set. Emitted as: +// "400 400 Model "claude-opus-5" has no AI credits pricing and no default pricing is configured." +// "400 Model "claude-opus-5" has no AI credits pricing" +// Captures the model name in group 1 for use in remediation guidance. +const MISSING_MODEL_PRICING_PATTERN = /Model\s+"([^"]+)"\s+has no AI credits pricing/i; + // Pattern: Copilot/CAPI quota exhaustion and rate-limit responses. // Matches all observed forms: // "CAPIError: 429 429 quota exceeded" (original observed form) @@ -128,12 +142,32 @@ function isInvocationCapExceededError(output) { return isMaxRunsExceededError(output); } +/** + * Normalize model names to a single safe line for GitHub Actions outputs and issue titles. + * @param {string} value + * @returns {string} + */ +function sanitizeModelName(value) { + return value.replace(/\r?\n|\r/g, " ").trim(); +} + +/** + * Extract model name from a "no AI credits pricing" error message. + * @param {string} logContent - Contents of the agent stdio log + * @returns {string} Model name, or empty string if not found + */ +function extractMissingModelPricingModelName(logContent) { + const match = logContent.match(MISSING_MODEL_PRICING_PATTERN); + return match ? sanitizeModelName(match[1]) : ""; +} + /** * 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, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean }} + * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }} */ function detectErrors(logContent) { + const missingModelPricingModelName = extractMissingModelPricingModelName(logContent); return { inferenceAccessError: INFERENCE_ACCESS_ERROR_PATTERN.test(logContent), mcpPolicyError: MCP_POLICY_BLOCKED_PATTERN.test(logContent), @@ -142,12 +176,14 @@ function detectErrors(logContent) { http400ResponseError: HTTP_400_RESPONSE_ERROR_PATTERN.test(logContent), capiQuotaExceededError: isCAPIQuotaExceededError(logContent), invocationCapExceeded: isInvocationCapExceededError(logContent), + missingModelPricingError: missingModelPricingModelName !== "", + missingModelPricingModelName, }; } /** * Build GitHub Actions output lines from detection results. - * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean }} results + * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }} results * @returns {string[]} */ function buildOutputLines(results) { @@ -160,12 +196,14 @@ function buildOutputLines(results) { `http_400_response_error=${results.http400ResponseError}`, `capi_quota_exceeded_error=${effectiveCAPIQuotaExceeded}`, `invocation_cap_exceeded=${results.invocationCapExceeded}`, + `missing_model_pricing_error=${results.missingModelPricingError}`, + `missing_model_pricing_model_name=${results.missingModelPricingModelName}`, ]; } /** * Write GitHub Actions outputs to $GITHUB_OUTPUT. - * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean }} results + * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }} results */ function writeOutputs(results) { const outputFile = process.env.GITHUB_OUTPUT; @@ -195,7 +233,22 @@ function main() { process.stderr.write(`[detect-agent-errors] Log file not found: ${LOG_FILE}\n`); } - const results = detectErrors(logContent); + const stdioResults = detectErrors(logContent); + + // Also check the AWF firewall structured JSONL logs for the `unknown_model_ai_credits` + // event — the API proxy event log is preferred and the audit log is used as a fallback. + // These logs carry both the error type and the model name, providing a more reliable + // detection source than text-scanning the stdio log. + const { detected: auditMissingPricing, modelName: auditModelName } = parseUnknownModelAICreditsAndModelFromAuditLog(); + if (auditMissingPricing && !stdioResults.missingModelPricingError) { + process.stderr.write(`[detect-agent-errors] Detected missing model pricing from firewall structured log: model "${auditModelName}" has no AI credits pricing configured\n`); + } + + const results = { + ...stdioResults, + missingModelPricingError: stdioResults.missingModelPricingError || auditMissingPricing, + missingModelPricingModelName: stdioResults.missingModelPricingModelName || sanitizeModelName(auditModelName), + }; if (results.inferenceAccessError) { process.stderr.write("[detect-agent-errors] Detected inference access error in agent log\n"); @@ -218,6 +271,9 @@ function main() { if (results.invocationCapExceeded) { process.stderr.write("[detect-agent-errors] Detected invocation cap exhaustion: the pooled per-run LLM invocation budget is fully saturated\n"); } + if (results.missingModelPricingError && !auditMissingPricing) { + process.stderr.write(`[detect-agent-errors] Detected missing model pricing: model "${results.missingModelPricingModelName}" has no AI credits pricing configured\n`); + } writeOutputs(results); } @@ -228,6 +284,7 @@ if (require.main === module) { module.exports = { detectErrors, + extractMissingModelPricingModelName, isCAPIQuotaExceededError, isInvocationCapExceededError, INFERENCE_ACCESS_ERROR_PATTERN, @@ -237,5 +294,6 @@ module.exports = { HTTP_400_RESPONSE_ERROR_PATTERN, CAPI_QUOTA_EXCEEDED_PATTERN, INVOCATION_CAP_EXCEEDED_PATTERN, + MISSING_MODEL_PRICING_PATTERN, buildOutputLines, }; diff --git a/setup/js/firewall_blocked_domains.cjs b/setup/js/firewall_blocked_domains.cjs index 8f5210b9..de4ef1e1 100644 --- a/setup/js/firewall_blocked_domains.cjs +++ b/setup/js/firewall_blocked_domains.cjs @@ -14,6 +14,18 @@ const { sanitizeDomainName } = require("./sanitize_content_core.cjs"); const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs"); const { renderMarkdownTemplate } = require("./render_template.cjs"); +// Internal AWF sidecar container hostnames added to network.topologyAttach by +// gh-aw itself (e.g. the MCP Gateway and the CLI proxy). These are +// framework-managed, not user-controllable external domains, and must never +// surface in the "blocked domains" warning shown on issues/PRs. +const AWF_INTERNAL_SIDECAR_HOSTS = ["awmg-mcpg", "awmg-cli-proxy"]; + +// Pre-compute sanitized forms at module load time. +// sanitizeDomainName strips non-alphanumeric characters (including hyphens), +// which is exactly how these container names appear after log sanitization +// (e.g. "awmg-mcpg" → "awmgmcpg", "awmg-cli-proxy" → "awmgcliproxy"). +const AWF_INTERNAL_SIDECAR_HOSTS_SANITIZED = new Set(AWF_INTERNAL_SIDECAR_HOSTS.map(h => sanitizeDomainName(h))); + /** * Parses a single firewall log line * Format: timestamp client_ip:port domain dest_ip:port proto method status decision url user_agent @@ -173,7 +185,7 @@ function getBlockedDomains(logsDir) { domainField = entry.destIpPort; } const sanitizedDomain = extractAndSanitizeDomain(domainField); - if (sanitizedDomain && sanitizedDomain !== "-") { + if (sanitizedDomain && sanitizedDomain !== "-" && !AWF_INTERNAL_SIDECAR_HOSTS_SANITIZED.has(sanitizedDomain)) { blockedDomainsSet.add(sanitizedDomain); } } diff --git a/setup/js/git_auth_helpers.cjs b/setup/js/git_auth_helpers.cjs index 7e1ee292..0208fd81 100644 --- a/setup/js/git_auth_helpers.cjs +++ b/setup/js/git_auth_helpers.cjs @@ -144,8 +144,41 @@ async function restorePersistedExtraheader(serverUrl, previousValues, cwd) { core.info(`git_auth_helpers: extraheader restored`); } +/** + * Temporarily override the persisted GitHub extraheader for remote git operations. + * + * Saves the current extraheader value(s), replaces them with the fork token for the + * duration of the callback, then restores the original value(s). This ensures that + * only one Authorization source is active at a time, preventing duplicate-header HTTP 400s + * when a fork token and the checkout-persisted upstream token both apply to the same host. + * + * @template T + * @param {string} token + * @param {() => Promise} callback + * @param {string} [cwd] - Optional working directory; scopes the git config override to the correct checkout + * @returns {Promise} + */ +async function withGitHubHostToken(token, callback, cwd) { + if (!token) { + return callback(); + } + const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); + let previousExtraheaders = []; + let overrideApplied = false; + try { + previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, token, cwd); + overrideApplied = true; + return await callback(); + } finally { + if (overrideApplied) { + await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd); + } + } +} + module.exports = { checkoutHasPersistedExtraheader, overridePersistedExtraheader, restorePersistedExtraheader, + withGitHubHostToken, }; diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 1dc0b64e..c3b9579f 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -19,6 +19,7 @@ const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp const { readDedupedTokenUsage, TOKEN_USAGE_PATHS } = require("./parse_token_usage.cjs"); const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); const fs = require("fs"); +const https = require("https"); const os = require("os"); const path = require("path"); @@ -245,6 +246,7 @@ function buildFailureMatchCategories(options) { 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.missingModelPricingError) categories.push("missing_model_pricing"); if (options.maxAICreditsExceeded) categories.push("max_ai_credits_exceeded"); if (options.hasAppTokenMintingFailed) categories.push("app_token_minting_failed"); if (options.hasLockdownCheckFailed) categories.push("lockdown_check_failed"); @@ -285,6 +287,8 @@ function buildFailureMatchCategories(options) { * @param {boolean} options.hasAssignmentErrors * @param {boolean} options.http400ResponseError * @param {boolean} options.unknownModelAICredits + * @param {boolean} [options.missingModelPricingError] + * @param {string} [options.missingModelPricingModelName] * @returns {string} */ function buildFailureIssueTitle(options) { @@ -292,6 +296,12 @@ 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`; + // Missing model pricing is surfaced by the proxy as HTTP 400, so prefer the + // specialized title before falling back to the generic transport-level error. + if (options.missingModelPricingError) { + const modelSuffix = options.missingModelPricingModelName ? ` (${options.missingModelPricingModelName})` : ""; + return `[aw] ${workflowName} has no AI credits pricing for model${modelSuffix}`; + } // 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`; @@ -1616,10 +1626,11 @@ function buildTimeoutContext(isTimedOut, timeoutMinutes) { * @param {string} agentConclusion * @param {boolean} hasToolDenialsExceeded * @param {boolean} isTimedOut + * @param {boolean} hasMissingModelPricingError * @returns {boolean} */ -function shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) { - return agentConclusion === "failure" && !hasToolDenialsExceeded && !isTimedOut; +function shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut, hasMissingModelPricingError = false) { + return agentConclusion === "failure" && !hasToolDenialsExceeded && !isTimedOut && !hasMissingModelPricingError; } /** @@ -1709,6 +1720,202 @@ function buildUnknownModelAICreditsContext(hasUnknownModelAICreditsError) { return "\n" + renderPromptTemplate("unknown_model_ai_credits.md"); } +/** + * Fetch the models.dev pricing catalog and look up per-million-token pricing for a model. + * Returns null when the catalog is unavailable, the model is not found, or pricing is missing. + * @param {string} modelName - The model name to look up (e.g. "claude-opus-5") + * @param {string} [providerName] - Preferred provider key (e.g. "anthropic") + * @returns {Promise<{input: number, output: number, cacheRead?: number, cacheWrite?: number}|null>} + */ +async function fetchModelPricingFromModelsDev(modelName, providerName = "") { + if (!modelName) return null; + const url = "https://models.dev/catalog.json"; + const normalizedModel = modelName.toLowerCase().replace(/[._]/g, "-"); + const normalizedProvider = (providerName || "").trim().toLowerCase(); + const MAX_MODELS_DEV_RESPONSE_BYTES = 2 * 1024 * 1024; + /** @type {string} */ + const rawJson = await new Promise((resolve, reject) => { + const req = https.get(url, res => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`models.dev returned HTTP ${res.statusCode}`)); + return; + } + let receivedBytes = 0; + const chunks = []; + res.on("data", chunk => { + receivedBytes += chunk.length; + if (receivedBytes > MAX_MODELS_DEV_RESPONSE_BYTES) { + req.destroy(new Error(`models.dev response exceeded ${MAX_MODELS_DEV_RESPONSE_BYTES} bytes`)); + return; + } + chunks.push(chunk); + }); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + res.on("error", reject); + }); + const hardDeadline = setTimeout(() => req.destroy(new Error("models.dev request timed out")), 5000); + req.on("close", () => clearTimeout(hardDeadline)); + req.on("error", reject); + }); + + /** @type {any} */ + let catalog; + try { + catalog = JSON.parse(rawJson); + } catch { + throw new Error("models.dev returned non-JSON response"); + } + const providers = catalog?.providers ?? {}; + + const providerEntries = Object.entries(providers); + const lookupOrder = normalizedProvider + ? [...providerEntries.filter(([provider]) => provider.toLowerCase() === normalizedProvider), ...providerEntries.filter(([provider]) => provider.toLowerCase() !== normalizedProvider)] + : providerEntries; + + for (const [, providerData] of lookupOrder) { + const models = /** @type {any} */ providerData?.models ?? {}; + for (const [mName, mData] of Object.entries(models)) { + const normalized = mName.toLowerCase().replace(/[._]/g, "-"); + if (normalized === normalizedModel) { + const cost = /** @type {any} */ mData?.cost ?? {}; + const inputPerMillion = typeof cost.input === "number" ? cost.input : null; + const outputPerMillion = typeof cost.output === "number" ? cost.output : null; + if (inputPerMillion === null || outputPerMillion === null) return null; + /** @type {{input: number, output: number, cacheRead?: number, cacheWrite?: number}} */ + const result = { input: inputPerMillion, output: outputPerMillion }; + if (typeof cost.cache_read === "number") result.cacheRead = cost.cache_read; + if (typeof cost.cache_write === "number") result.cacheWrite = cost.cache_write; + return result; + } + } + } + return null; +} + +/** + * Format a per-million-token price as a YAML-safe per-token scientific notation string. + * @param {number} perMillionTokens + * @returns {string} + */ +function formatPerTokenPrice(perMillionTokens) { + const perToken = perMillionTokens / 1_000_000; + return perToken.toExponential().replace(/e\+?(-?)0*(\d+)$/, "e$1$2"); +} + +/** + * Infer the frontmatter provider key from the engine ID. + * @param {string} engineId + * @returns {string} + */ +function inferProviderKeyFromEngineId(engineId) { + switch ((engineId || "").toLowerCase()) { + case "claude": + return "anthropic"; + case "codex": + return "openai"; + case "copilot": + return "github-copilot"; + default: + return "github-copilot"; + } +} + +/** + * @param {string} value + * @returns {string} + */ +function quoteYAMLKey(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** + * Build a frontmatter YAML pricing snippet for the missing model. + * Returns null when pricing data is unavailable. + * @param {string} modelName + * @param {string} engineId + * @param {{input: number, output: number, cacheRead?: number, cacheWrite?: number}|null} pricing Per-million-token values from models.dev + * @returns {string|null} + */ +function buildModelPricingFrontmatterSnippet(modelName, engineId, pricing, isPlaceholderPricing = false) { + if (!modelName || !pricing) return null; + const provider = inferProviderKeyFromEngineId(engineId); + const inputStr = formatPerTokenPrice(pricing.input); + const outputStr = formatPerTokenPrice(pricing.output); + const quotedModelName = quoteYAMLKey(modelName); + let costBlock = ""; + if (isPlaceholderPricing) { + costBlock += " # Placeholder values — replace with actual pricing for this model\n"; + } + costBlock += ` input: "${inputStr}" # $${pricing.input.toFixed(2)} per million input tokens\n`; + costBlock += ` output: "${outputStr}" # $${pricing.output.toFixed(2)} per million output tokens\n`; + if (pricing.cacheRead !== undefined) { + costBlock += ` cache_read: "${formatPerTokenPrice(pricing.cacheRead)}" # $${pricing.cacheRead.toFixed(2)} per million cache-read tokens\n`; + } + if (pricing.cacheWrite !== undefined) { + costBlock += ` cache_write: "${formatPerTokenPrice(pricing.cacheWrite)}" # $${pricing.cacheWrite.toFixed(2)} per million cache-write tokens\n`; + } + return `\`\`\`yaml +models: + providers: + ${provider}: + models: + ${quotedModelName}: + cost: +${costBlock.trimEnd()} +\`\`\``; +} + +/** + * Build a frontmatter YAML pricing skeleton for manual completion when live pricing is unavailable. + * @param {string} modelName + * @param {string} engineId + * @returns {string|null} + */ +function buildManualModelPricingFrontmatterSnippet(modelName, engineId) { + return buildModelPricingFrontmatterSnippet(modelName, engineId, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, true); +} + +/** + * Builds the missing_model_pricing failure context block for templates. + * Fetches current pricing from models.dev and includes a ready-to-use frontmatter snippet. + * @param {boolean} hasMissingModelPricingError + * @param {string} modelName + * @param {string} engineId + * @returns {Promise} + */ +async function buildMissingModelPricingContext(hasMissingModelPricingError, modelName, engineId) { + if (!hasMissingModelPricingError) { + return ""; + } + + const resolvedModelName = modelName || "unknown"; + let pricingSnippet = buildManualModelPricingFrontmatterSnippet(resolvedModelName, engineId) || ""; + if (modelName) { + try { + const pricing = await fetchModelPricingFromModelsDev(modelName, inferProviderKeyFromEngineId(engineId)); + if (pricing) { + const snippet = buildModelPricingFrontmatterSnippet(resolvedModelName, engineId, pricing); + if (snippet) { + pricingSnippet = snippet; + } + } + } catch (err) { + core.info(`Could not fetch pricing from models.dev for model "${modelName}": ${getErrorMessage(err)}`); + } + } + + return ( + "\n" + + renderPromptTemplate("missing_model_pricing.md", { + model_name: resolvedModelName, + model_name_yaml_key: quoteYAMLKey(resolvedModelName), + pricing_snippet: pricingSnippet, + has_pricing_snippet: pricingSnippet ? "true" : "", + }) + ); +} + /** * Detect HTTP 429/rate-limit engine failures in text payloads. * @param {string} content @@ -2865,6 +3072,8 @@ async function main() { const unknownModelAICreditsFromOutput = process.env.GH_AW_UNKNOWN_MODEL_AI_CREDITS === "true"; const unknownModelAICreditsFromAudit = parseUnknownModelAICreditsFromAuditLog(); const unknownModelAICredits = unknownModelAICreditsFromAudit || (unknownModelAICreditsFromOutput && agentConclusion === "failure"); + const missingModelPricingError = process.env.GH_AW_MISSING_MODEL_PRICING_ERROR === "true" && agentConclusion === "failure"; + const missingModelPricingModelName = process.env.GH_AW_MISSING_MODEL_PRICING_MODEL_NAME || ""; const pushRepoMemoryResult = process.env.GH_AW_PUSH_REPO_MEMORY_RESULT || ""; 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) @@ -2977,6 +3186,7 @@ async function main() { 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(`Missing model pricing error: ${missingModelPricingError} (model: ${missingModelPricingModelName || "(unknown)"})`); core.info(`Push repo-memory result: ${pushRepoMemoryResult}`); core.info(`App token minting failed (safe_outputs/conclusion/activation): ${safeOutputsAppTokenMintingFailed}/${conclusionAppTokenMintingFailed}/${activationAppTokenMintingFailed}`); core.info(`Lockdown check failed: ${hasLockdownCheckFailed}`); @@ -3274,6 +3484,8 @@ async function main() { hasAssignmentErrors, http400ResponseError, unknownModelAICredits, + missingModelPricingError, + missingModelPricingModelName, }); const failureCategories = buildFailureMatchCategories({ agentConclusion, @@ -3298,6 +3510,7 @@ async function main() { http400ResponseError, aiCreditsRateLimitError, unknownModelAICredits, + missingModelPricingError, maxAICreditsExceeded, hasAppTokenMintingFailed, hasLockdownCheckFailed, @@ -3373,6 +3586,10 @@ async function main() { failureCategories, }); + // Build missing model pricing context once; both issue-create and issue-comment + // paths render the same remediation block and should not refetch models.dev. + const missingModelPricingContext = await buildMissingModelPricingContext(missingModelPricingError, missingModelPricingModelName, process.env.GH_AW_ENGINE_ID || ""); + if (existingIssue) { // Issue exists, add a comment core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); @@ -3445,8 +3662,9 @@ async function main() { // Suppress when tool-denials-exceeded is present: the engine termination is a // direct consequence of the SDK hitting the denial threshold, so the tool-denials // context is the more actionable signal. - const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; - + // Also suppress when missing-model-pricing is detected: the pricing error is the + // root cause and the engine error block would be redundant noise. + const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut, missingModelPricingError) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; // Build timeout context const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes); @@ -3516,6 +3734,7 @@ async function main() { http_400_response_error_context: http400ResponseErrorContext, ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext, unknown_model_ai_credits_context: unknownModelAICreditsContext, + missing_model_pricing_context: missingModelPricingContext, app_token_minting_failed_context: appTokenMintingFailedContext, lockdown_check_failed_context: lockdownCheckFailedContext, oauth_token_check_failed_context: oauthTokenCheckFailedContext, @@ -3664,7 +3883,9 @@ async function main() { // Suppress when tool-denials-exceeded is present: the engine termination is a // direct consequence of the SDK hitting the denial threshold, so the tool-denials // context is the more actionable signal. - const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; + // Also suppress when missing-model-pricing is detected: the pricing error is the + // root cause and the engine error block would be redundant noise. + const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut, missingModelPricingError) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; // Build timeout context const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes); @@ -3739,6 +3960,7 @@ async function main() { http_400_response_error_context: http400ResponseErrorContext, ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext, unknown_model_ai_credits_context: unknownModelAICreditsContext, + missing_model_pricing_context: missingModelPricingContext, app_token_minting_failed_context: appTokenMintingFailedContext, lockdown_check_failed_context: lockdownCheckFailedContext, oauth_token_check_failed_context: oauthTokenCheckFailedContext, @@ -3851,6 +4073,9 @@ module.exports = { buildAssignmentErrorsContext, buildAICreditsRateLimitErrorContext, buildUnknownModelAICreditsContext, + buildMissingModelPricingContext, + buildModelPricingFrontmatterSnippet, + fetchModelPricingFromModelsDev, hasEngineMaxRunsExceededSignal, hasEngineRateLimit429Signal, hasEngineRateLimit429InOTELMirror, diff --git a/setup/js/log_parser_shared.cjs b/setup/js/log_parser_shared.cjs index f8828c60..bfc1cf2a 100644 --- a/setup/js/log_parser_shared.cjs +++ b/setup/js/log_parser_shared.cjs @@ -1344,6 +1344,15 @@ function formatSafeOutputsPreview(safeOutputsContent, options = {}) { preview.push(""); preview.push(""); } + + if (entry.data !== undefined) { + const dataString = truncateString(JSON.stringify(entry.data, null, 2), 400); + preview.push("**Data:**"); + preview.push("```json"); + preview.push(dataString); + preview.push("```"); + preview.push(""); + } } if (hasMore) { diff --git a/setup/js/mcp_scripts_validation.cjs b/setup/js/mcp_scripts_validation.cjs index e24eecdb..19d58898 100644 --- a/setup/js/mcp_scripts_validation.cjs +++ b/setup/js/mcp_scripts_validation.cjs @@ -276,6 +276,10 @@ function validateArgumentsAgainstSchema(args, inputSchema) { return validateSchemaNode(args, inputSchema, "", { skipRequiredAtRoot: true }); } +function validateValueAgainstSchema(value, schema) { + return validateSchemaNode(value, schema, "", { skipRequiredAtRoot: false }); +} + function formatSchemaValidationError(toolName, args, error) { if (toolName === "add_labels" && typeof error?.path === "string" && /^labels\[\d+\]$/.test(error.path) && Array.isArray(args?.labels)) { const index = Number(error.path.match(/^labels\[(\d+)\]$/)?.[1] || -1); @@ -308,6 +312,7 @@ module.exports = { buildStringLengthValidationError, validateStringMinLengths, validateArgumentsAgainstSchema, + validateValueAgainstSchema, formatSchemaValidationError, MAX_STRING_INPUT_BYTES, }; diff --git a/setup/js/models.json b/setup/js/models.json index 4b32e752..351e3366 100644 --- a/setup/js/models.json +++ b/setup/js/models.json @@ -386,6 +386,15 @@ "provider_type": "openai", "wire_api": "completions" }, + "gemini-3.6-flash": { + "cost": { + "input": "1.5e-06", + "output": "7.5e-06", + "cache_read": "1.5e-07" + }, + "provider_type": "openai", + "wire_api": "completions" + }, "gpt-4.1": { "cost": { "input": "2e-06", diff --git a/setup/js/mount_mcp_as_cli.cjs b/setup/js/mount_mcp_as_cli.cjs index f43d302c..81164917 100644 --- a/setup/js/mount_mcp_as_cli.cjs +++ b/setup/js/mount_mcp_as_cli.cjs @@ -61,8 +61,45 @@ function loadToolsFromJSONFile(toolsPath, core) { } /** - * Recover safeoutputs tools from the generated safe-outputs tools.json when MCP - * tools/list returned an empty result. + * Return the path where the safeoutputs gateway-empty flag file is written. + * The path is computed at call time (not module load time) so that tests can + * control the location by setting process.env.RUNNER_TEMP. + * + * @returns {string} + */ +function getSafeOutputsGatewayEmptyFlagPath() { + const runnerTemp = process.env.RUNNER_TEMP || "/home/runner/work/_temp"; + return path.join(runnerTemp, "gh-aw", "safeoutputs", "gateway_empty.flag"); +} + +/** + * Write a flag file that signals the safeoutputs MCP gateway registered 0 tools. + * collect_ndjson_output.cjs reads this flag and fails the conclusion job with a + * clear infra error instead of silently treating the missing outputs.jsonl as a + * graceful no-op. + * + * Failures are non-fatal — the flag is best-effort. A warning is emitted if the + * write fails so that the issue is still surfaced in the step log. + * + * @param {typeof import("@actions/core")} core + */ +function writeSafeOutputsGatewayEmptyFlag(core) { + const flagPath = getSafeOutputsGatewayEmptyFlagPath(); + try { + fs.mkdirSync(path.dirname(flagPath), { recursive: true }); + fs.writeFileSync(flagPath, "", { flag: "w" }); + } catch (err) { + core.warning(`Failed to write safeoutputs gateway-empty flag at ${flagPath}: ${getErrorMessage(err)}`); + } +} + +/** + * Validate the safeoutputs tool list and fail fast when the live gateway is empty. + * + * When the live gateway returns 0 tools, a flag file is written so that the + * conclusion job (collect_ndjson_output.cjs) can detect the outage and fail with + * a clear infra error instead of treating the missing outputs.jsonl as a graceful + * no-op. * * @param {Array<{name: string, description?: string, inputSchema?: unknown}>} tools * @param {typeof import("@actions/core")} core @@ -72,13 +109,13 @@ function recoverSafeOutputsToolsIfNeeded(tools, core) { if (tools.length > 0) { return tools; } - const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || `${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json`; - const recovered = loadToolsFromJSONFile(fallbackPath, core); - if (recovered.length > 0) { - core.warning(` safeoutputs tools/list returned empty; recovered ${recovered.length} tool(s) from ${fallbackPath}`); - return recovered; - } - throw new Error(`safeoutputs tool schema is empty (tools/list returned 0 and fallback ${fallbackPath} is empty/missing). ` + `Failing fast to avoid agent runs without discoverable safe-output tools.`); + + // The live MCP gateway returned 0 tools for safeoutputs. Write a flag file so + // that collect_ndjson_output.cjs can surface this as a hard failure instead of + // silently concluding "graceful no-op" when outputs.jsonl is never written. + writeSafeOutputsGatewayEmptyFlag(core); + + throw new Error(`safeoutputs tools/list returned 0 tools. ` + `Failing fast — the live MCP gateway has no tools registered. ` + `Check the MCP gateway startup logs for ECONNRESET errors or delayed backend registration.`); } /** @@ -552,6 +589,8 @@ module.exports = { toContainerUrl, loadToolsFromJSONFile, recoverSafeOutputsToolsIfNeeded, + getSafeOutputsGatewayEmptyFlagPath, + writeSafeOutputsGatewayEmptyFlag, SERVER_VALIDATORS, buildMCPCLIServersPromptList, }; diff --git a/setup/js/package.json b/setup/js/package.json index 5132d10d..d11c0031 100644 --- a/setup/js/package.json +++ b/setup/js/package.json @@ -7,11 +7,11 @@ "@actions/github-script": "github:actions/github-script#v9.0.0", "@actions/glob": "^0.7.0", "@actions/io": "^3.0.2", - "@github/copilot-sdk": "^1.0.7", + "@github/copilot-sdk": "^1.0.8", "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.8", "@vitest/ui": "^4.1.10", - "minimatch": ">=3.1.3", + "minimatch": ">=10.2.6", "prettier": "^3.9.6", "typescript": "^7.0.2", "vite": "^8.1.5", diff --git a/setup/js/parse_firewall_logs.cjs b/setup/js/parse_firewall_logs.cjs index daa9454a..4bafffd9 100644 --- a/setup/js/parse_firewall_logs.cjs +++ b/setup/js/parse_firewall_logs.cjs @@ -7,6 +7,24 @@ const { sanitizeWorkflowName } = require("./sanitize_workflow_name.cjs"); const { ERR_PARSE } = require("./error_codes.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +// Internal AWF sidecar container hostnames added to network.topologyAttach by +// gh-aw itself. These are framework-managed and should be excluded from blocked +// domain reporting in step summaries so they do not appear as actionable items. +const AWF_INTERNAL_SIDECAR_HOSTS = new Set(["awmg-mcpg", "awmg-cli-proxy"]); + +/** + * Returns true when domainKey refers to a framework-internal sidecar container. + * domainKey may be "hostname:port" or bare "hostname". + * @param {string} domainKey + * @returns {boolean} + */ +function isInternalSidecarHost(domainKey) { + if (!domainKey || domainKey === "-") return false; + const lastColon = domainKey.lastIndexOf(":"); + const host = lastColon > 0 ? domainKey.substring(0, lastColon) : domainKey; + return AWF_INTERNAL_SIDECAR_HOSTS.has(host); +} + /** * Parses firewall logs and creates a step summary * Firewall log format: timestamp client_ip:port domain dest_ip:port proto method status decision url user_agent @@ -190,19 +208,28 @@ function analyzeFirewallLogLines(lines) { allowedRequests++; allowedDomains.add(domainKey); } else { - blockedRequests++; - blockedDomains.add(domainKey); + // Skip internal sidecar hostnames (awmg-mcpg, awmg-cli-proxy) from the + // blocked domain set. These are framework-managed topology-attach containers + // and are not user-actionable external blocked domains. + if (!isInternalSidecarHost(domainKey)) { + blockedRequests++; + blockedDomains.add(domainKey); + } } - // Track request count per domain - if (!requestsByDomain.has(domainKey)) { - requestsByDomain.set(domainKey, { allowed: 0, blocked: 0 }); - } - const domainStats = requestsByDomain.get(domainKey); - if (isAllowed) { - domainStats.allowed++; - } else { - domainStats.blocked++; + // Track request count per domain. + // Skip internal sidecar hostnames for blocked entries — they are already excluded from + // blockedRequests/blockedDomains above and must not appear in the summary domain table. + if (isAllowed || !isInternalSidecarHost(domainKey)) { + if (!requestsByDomain.has(domainKey)) { + requestsByDomain.set(domainKey, { allowed: 0, blocked: 0 }); + } + const domainStats = requestsByDomain.get(domainKey); + if (isAllowed) { + domainStats.allowed++; + } else { + domainStats.blocked++; + } } } @@ -266,6 +293,7 @@ if (typeof module !== "undefined" && module.exports) { isRequestAllowed, analyzeFirewallLogLines, generateFirewallSummary, + isInternalSidecarHost, main, }; } diff --git a/setup/js/pr_review_buffer.cjs b/setup/js/pr_review_buffer.cjs index 3a3e3cf2..56bd1a19 100644 --- a/setup/js/pr_review_buffer.cjs +++ b/setup/js/pr_review_buffer.cjs @@ -112,6 +112,9 @@ function createReviewBuffer() { /** @type {boolean} When true, dismiss older same-workflow REQUEST_CHANGES reviews after posting a replacement review. */ let supersedeOlderReviews = false; + /** @type {string} When non-empty, pins the review to this commit SHA instead of the live PR head or GH_AW_HEAD_SHA. */ + let pinnedCommitId = ""; + /** * Best-effort execution-state capture. * When the installation token is out of quota, metadata collection should not @@ -245,6 +248,17 @@ function createReviewBuffer() { } } + /** + * Pin the review to a specific commit SHA, overriding GH_AW_HEAD_SHA and the live PR head. + * @param {string} commitId - The commit SHA to pin the review to + */ + function setPinnedCommitId(commitId) { + if (commitId && typeof commitId === "string") { + pinnedCommitId = commitId; + core.info(`PR review pinned to commit: ${commitId}`); + } + } + /** * Check if there are buffered comments to submit. * @returns {boolean} @@ -298,6 +312,20 @@ function createReviewBuffer() { return { success: false, error: "Pull request head SHA not available" }; } + // Use the head SHA captured at trigger time (GH_AW_HEAD_SHA, injected by the compiler) + // when available, falling back to the live PR head SHA. This pins the review to the + // commit the agent actually reviewed, preventing attribution drift when new commits are + // pushed during the run (most common under workflow_run triggers where the safe_outputs + // job runs after the agent job and pulls.get() may return a newer HEAD sha). + // A user-specified pinnedCommitId (from the commit-id config option) takes highest priority. + const awHeadSHA = process.env.GH_AW_HEAD_SHA || ""; + const resolvedCommitId = pinnedCommitId || awHeadSHA || pullRequest.head.sha; + if (pinnedCommitId && pinnedCommitId !== pullRequest.head.sha) { + core.info(`Using config-pinned commit SHA: ${pinnedCommitId} (PR head is now ${pullRequest.head.sha})`); + } else if (awHeadSHA && awHeadSHA !== pullRequest.head.sha) { + core.info(`Using trigger-time head SHA: ${awHeadSHA} (PR head is now ${pullRequest.head.sha})`); + } + // Determine review event and body let event = reviewMetadata ? reviewMetadata.event : "COMMENT"; let body = reviewMetadata ? reviewMetadata.body : ""; @@ -465,7 +493,7 @@ function createReviewBuffer() { owner: repoParts.owner, repo: repoParts.repo, pull_number: pullRequestNumber, - commit_id: pullRequest.head.sha, + commit_id: resolvedCommitId, event: event, }; @@ -774,6 +802,7 @@ function createReviewBuffer() { setIncludeFooter: setFooterMode, // Backward compatibility alias setStaged, setSupersedeOlderReviews, + setPinnedCommitId, hasBufferedComments, hasReviewMetadata, getBufferedCount, @@ -806,6 +835,8 @@ function createPrReviewBufferRegistry() { let defaultFooterContext = null; let defaultStaged = false; let defaultSupersedeOlderReviews = false; + /** @type {string} */ + let defaultPinnedCommitId = ""; /** * Get or create the buffer for the given (repo, prNumber) pair. @@ -831,6 +862,9 @@ function createPrReviewBufferRegistry() { if (defaultSupersedeOlderReviews) { buffer.setSupersedeOlderReviews(true); } + if (defaultPinnedCommitId) { + buffer.setPinnedCommitId(defaultPinnedCommitId); + } bufferMap.set(k, buffer); insertionOrder.push({ repo, prNumber, buffer }); core.info(`PR review registry: created buffer for ${repo}#${prNumber}`); @@ -874,6 +908,13 @@ function createPrReviewBufferRegistry() { defaultSupersedeOlderReviews = value === true; } + /** @param {string} value */ + function setDefaultPinnedCommitId(value) { + if (value && typeof value === "string") { + defaultPinnedCommitId = value; + } + } + return { getOrCreate, getAllEntries, @@ -882,6 +923,7 @@ function createPrReviewBufferRegistry() { setDefaultFooterContext, setDefaultStaged, setDefaultSupersedeOlderReviews, + setDefaultPinnedCommitId, }; } diff --git a/setup/js/process_runner.cjs b/setup/js/process_runner.cjs index 771f1942..2cdcdfe1 100644 --- a/setup/js/process_runner.cjs +++ b/setup/js/process_runner.cjs @@ -202,6 +202,29 @@ function runProcess({ command, args, attempt, log, logArgs, env, postResultWatch }); } +// Post-result watchdog: shared constants and timeout resolver used by all harnesses. +// These are kept here so both copilot_harness and codex_harness stay in sync. +const MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS = 50; +const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 20 * 1000; +/** Maximum allowed value for GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS to prevent the watchdog from being + * effectively disabled by an excessively large override (e.g. a stray zero). */ +const MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Resolve the post-result watchdog inactivity timeout from the environment. + * Falls back to DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS when unset or invalid. + * Clamps to [MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS]. + * @param {NodeJS.ProcessEnv} [env] + * @returns {number} + */ +function resolvePostResultWatchdogIdleTimeoutMs(env = process.env) { + const configuredTimeoutMs = Number(env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS); + if (!Number.isFinite(configuredTimeoutMs) || configuredTimeoutMs <= 0) { + return DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS; + } + return Math.min(MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, Math.max(MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, configuredTimeoutMs)); +} + /** * @param {NodeJS.ProcessEnv} [env] * @returns {boolean} @@ -251,5 +274,15 @@ function buildCopilotSDKEnv(env) { } if (typeof module !== "undefined" && module.exports) { - module.exports = { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv }; + module.exports = { + runProcess, + formatDuration, + sleep, + isCopilotSDKEnabled, + buildCopilotSDKEnv, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, + resolvePostResultWatchdogIdleTimeoutMs, + }; } diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs index 2ac9e1e2..e29228d2 100644 --- a/setup/js/push_to_pull_request_branch.cjs +++ b/setup/js/push_to_pull_request_branch.cjs @@ -17,7 +17,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); -const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); +const { withGitHubHostToken } = require("./git_auth_helpers.cjs"); const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); @@ -103,33 +103,6 @@ function parsePositiveInteger(value) { return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } -/** - * Temporarily override the persisted GitHub extraheader for remote git operations. - * - * @template T - * @param {string} token - * @param {() => Promise} callback - * @param {string} [cwd] - Optional working directory; scopes the git config override to the correct checkout - * @returns {Promise} - */ -async function withGitHubHostToken(token, callback, cwd) { - if (!token) { - return callback(); - } - const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); - let previousExtraheaders = []; - let overrideApplied = false; - try { - previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, token, cwd); - overrideApplied = true; - return await callback(); - } finally { - if (overrideApplied) { - await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd); - } - } -} - /** * Uses git as the source of truth for the files modified by a fetched bundle ref. * diff --git a/setup/js/render_template.cjs b/setup/js/render_template.cjs index 162a07c9..c90f039c 100644 --- a/setup/js/render_template.cjs +++ b/setup/js/render_template.cjs @@ -73,7 +73,11 @@ function renderMarkdownTemplate(markdown) { } else { removedBlocks++; core.info(`[renderMarkdownTemplate] Action: Removing entire block`); - return ""; + // Keep the leading newline so the line before the block stays separated + // from the line after it (the closing tag's trailing newline is already + // consumed by the match). Dropping it merges unrelated lines together, + // e.g. "Before\n{{#if false}}...{{/if}}\nAfter" -> "BeforeAfter". + return leadNL; } }); diff --git a/setup/js/safe_output_summary.cjs b/setup/js/safe_output_summary.cjs index 299b3e30..600c60db 100644 --- a/setup/js/safe_output_summary.cjs +++ b/setup/js/safe_output_summary.cjs @@ -143,6 +143,15 @@ function generateSafeOutputSummary(options) { // secrecy indicates the confidentiality level of the message content. // integrity indicates the trustworthiness level of the message source. if (message) { + if (message.data !== undefined) { + let renderedData = ""; + try { + renderedData = JSON.stringify(message.data, null, 2); + } catch { + renderedData = String(message.data); + } + summary += `**Data:**\n\`\`\`\`\`\`json\n${renderedData}\n\`\`\`\`\`\`\n\n`; + } if (message.secrecy !== undefined && message.secrecy !== null) { summary += `**Secrecy:** \`${message.secrecy}\`\n\n`; } diff --git a/setup/js/safe_output_type_validator.cjs b/setup/js/safe_output_type_validator.cjs index 430a72ba..6e5a1a58 100644 --- a/setup/js/safe_output_type_validator.cjs +++ b/setup/js/safe_output_type_validator.cjs @@ -13,6 +13,8 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { unfenceMarkdown } = require("./markdown_unfencing.cjs"); +const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs"); +const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); /** * Default max body length for GitHub content @@ -27,7 +29,13 @@ const MAX_GITHUB_USERNAME_LENGTH = 39; const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280; /** - * @typedef {{ allowedAliases?: string[], maxBotMentions?: number, normalizeIssueClosingKeywords?: boolean }} ValidateOptions + * @typedef {{ + * allowedAliases?: string[], + * maxBotMentions?: number, + * normalizeIssueClosingKeywords?: boolean, + * dataEnabled?: boolean, + * dataSchema?: any + * }} ValidateOptions */ // GitHub issue-closing keywords: @@ -40,6 +48,7 @@ const ISSUE_CLOSING_KEYWORD_BACKTICK_PATTERN = new RegExp(`\`(\\b(?:${ISSUE_CLOS const ISSUE_CLOSING_REFERENCE_BACKTICK_PATTERN = new RegExp(`(\\b(?:${ISSUE_CLOSING_KEYWORDS})\\b)(\\s+)\`(${ISSUE_REFERENCE_PATTERN})\``, "gi"); const NORMALIZE_CLOSER_BODY_TYPES = new Set(["create_issue", "add_comment", "create_pull_request"]); const ISSUE_INTENT_LABEL_TYPES = new Set(["add_labels", "remove_labels", "update_issue"]); +const STRUCTURED_DATA_LABEL = "Structured data:"; /** * Remove markdown backticks around recognized issue-closing keyword references. @@ -206,6 +215,8 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options) * @property {number} defaultMax - Default max count for this type * @property {Object.} fields - Field validation rules * @property {string} [customValidation] - Custom validation rule identifier + * @property {boolean} [dataEnabled] - Whether structured data is enabled for this type + * @property {any} [dataSchema] - Optional schema used to validate structured data */ /** @type {Object.|null} */ @@ -710,6 +721,72 @@ function validateItem(item, itemType, lineNum, options) { return { isValid: false, error: errors[0] }; // Return first error } + if (item.data !== undefined) { + const runtimeDataSchema = options?.dataSchema; + const runtimeDataEnabled = options?.dataEnabled === true || runtimeDataSchema !== undefined; + const configDataEnabled = typeConfig.dataEnabled === true || typeConfig.dataSchema !== undefined; + const dataEnabled = runtimeDataEnabled || configDataEnabled; + if (!dataEnabled) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' is not enabled (set safe-outputs.data in workflow frontmatter)`, + }; + } + if (!item.data || typeof item.data !== "object" || Array.isArray(item.data)) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' must be an object`, + }; + } + + let dataJSON; + let normalizedData; + try { + dataJSON = JSON.stringify(item.data, null, 2); + normalizedData = JSON.parse(dataJSON); + } catch { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' must be JSON-serializable`, + }; + } + + // Preserve normalized data on the item for downstream automation. + normalizedItem.data = normalizedData; + + const schemaSource = runtimeDataSchema !== undefined ? runtimeDataSchema : typeConfig.dataSchema; + if (schemaSource !== undefined) { + let dataSchema; + try { + dataSchema = resolveDataSchema(schemaSource, `safe-outputs.${itemType}.data`); + } catch (error) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data' schema is invalid: ${getErrorMessage(error)}`, + }; + } + const dataSchemaError = validateValueAgainstSchema(normalizedData, dataSchema); + if (dataSchemaError) { + const errorPath = dataSchemaError.path ? `.${dataSchemaError.path}` : ""; + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'data'${errorPath} ${dataSchemaError.message}`, + }; + } + } + + // If this safe-output type supports a body field, append structured data + // as fenced JSON so it survives body sanitization. + if (Object.prototype.hasOwnProperty.call(typeConfig.fields, "body")) { + const dataBlock = `${STRUCTURED_DATA_LABEL}\n\`\`\`json\n${dataJSON}\n\`\`\``; + if (typeof normalizedItem.body === "string" && normalizedItem.body.length > 0) { + normalizedItem.body = `${normalizedItem.body}\n\n${dataBlock}`; + } else { + normalizedItem.body = dataBlock; + } + } + } + return { isValid: true, normalizedItem }; } diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index 0af94ff3..b8206285 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -28,6 +28,8 @@ const { validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); const { lstatGuard } = require("./symlink_guard.cjs"); +const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs"); +const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); /** PR event names used for target:triggering context validation across all safe-output handlers. */ const PR_EVENT_NAMES = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); @@ -373,6 +375,29 @@ function createHandlers(server, appendSafeOutput, config = {}) { */ const defaultHandler = type => args => { const entry = { ...(args || {}), type }; + if (entry.data !== undefined) { + const toolConfig = getSafeOutputsToolConfig(config, type); + const dataEnabled = toolConfig?.data_enabled === true || (toolConfig?.data_schema && typeof toolConfig.data_schema === "object"); + if (!dataEnabled) { + return buildIntentErrorResponse(`${type} data is not enabled (set safe-outputs.data in workflow frontmatter)`); + } + /** @type {Record|null} */ + let dataSchema = null; + try { + if (toolConfig?.data_schema !== undefined) { + dataSchema = resolveDataSchema(toolConfig.data_schema, `safe-outputs.${type}.data`); + } + } catch (error) { + return buildIntentErrorResponse(`${type} data schema is invalid: ${getErrorMessage(error)}`); + } + if (dataSchema) { + const dataSchemaError = validateValueAgainstSchema(entry.data, dataSchema); + if (dataSchemaError) { + const errorPath = dataSchemaError.path ? `.${dataSchemaError.path}` : ""; + return buildIntentErrorResponse(`${type} data${errorPath} ${dataSchemaError.message}`); + } + } + } const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); if (wildcardTargetValidationError) { return wildcardTargetValidationError; diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 3fc78921..90d3ad81 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -1,20 +1,27 @@ [ { "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. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", + "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. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", "inputSchema": { "type": "object", "required": ["title", "body"], + "$defs": { + "structured_data": { + "type": "object", + "description": "Optional structured data to carry machine-readable context through sanitization-safe channels. When provided, this object is preserved and appended to the body as fenced JSON.", + "additionalProperties": true + } + }, "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", "minLength": 20, "maxLength": 65536, - "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", "string"], @@ -44,12 +51,12 @@ }, "parent": { "type": ["number", "string"], - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'." }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", + "description": "Unique temporary identifier for this issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Use this same '#aw_ID' form in body text to cross-reference the issue; these references are replaced with the real issue number after creation.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -285,7 +292,7 @@ }, { "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. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", + "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. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", "inputSchema": { "type": "object", "required": ["body"], @@ -293,11 +300,11 @@ "body": { "type": "string", "maxLength": 65536, - "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. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." + "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. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated." }, "item_number": { "type": ["number", "string"], - "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers — it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers \u2014 it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the tool call will fail with an error. Required when safe-outputs.add-comment.target is '*' (any item): calls without item_number (or pr_number/pr alias) are rejected. NOTE: this field is named item_number, NOT issue_number.", "x-synonyms": ["issue_number", "itemNumber"] }, "pr_number": { @@ -312,12 +319,12 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", + "description": "Unique temporary identifier for this comment. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted and normalised to '#aw_abc1'. Auto-generated if not provided. The temporary ID is returned in the tool response so you can reference this comment later.", "x-synonyms": ["temporaryId"] }, "reply_to_id": { "type": "string", - "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments — ignored for issue and pull request comments.", + "description": "Node ID of the discussion comment to reply to, enabling threaded discussion comments. When provided, the new comment is posted as a reply to the specified top-level discussion comment. If the given node ID belongs to a nested reply, the handler automatically resolves it to the top-level parent. Only applicable for discussion comments \u2014 ignored for issue and pull request comments.", "x-synonyms": ["replyToId"] }, "comment_id": { @@ -390,7 +397,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", + "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -427,7 +434,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the comment to fail.", + "description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the comment to fail.", "x-synonyms": ["pullRequestNumber"] }, "start_line": { @@ -481,7 +488,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) — omitting it will cause the review to fail.", + "description": "Pull request number to submit the review on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, submits the review on the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the review to fail.", "x-synonyms": ["pullRequestNumber"] }, "repo": { @@ -698,12 +705,12 @@ } ] }, - "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required — omitting it will cause a validation error." + "description": "Labels to add (e.g., ['bug', 'priority-high']). Each entry can be either a label name string or an object with name plus optional rationale/confidence/suggest intent metadata. Labels must exist in the repository. This field is required \u2014 omitting it will cause a validation error." }, "item_number": { "type": ["number", "string"], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required — omitting it will silently skip the label operation.", + "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required \u2014 omitting it will silently skip the label operation.", "x-synonyms": ["itemNumber"] }, "secrecy": { @@ -764,7 +771,7 @@ "item_number": { "type": ["number", "string"], "pattern": "^(\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", + "description": "Issue or PR number to remove labels from. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. If omitted, removes labels from the item that triggered this workflow.", "x-synonyms": ["itemNumber"] }, "secrecy": { @@ -803,7 +810,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required — omitting it will silently skip the reviewer assignment.", + "description": "Pull request number to add reviewers to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds reviewers to the PR that triggered this workflow. Only works for pull_request event triggers. For workflow_dispatch, schedule, or other triggers, pull_request_number is required \u2014 omitting it will silently skip the reviewer assignment.", "x-synonyms": ["pullRequestNumber"] }, "secrecy": { @@ -827,7 +834,7 @@ "properties": { "issue_number": { "type": ["number", "string"], - "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run — use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", + "description": "Issue number to assign to the milestone. This is the numeric ID from the GitHub URL (e.g., 567 in github.com/owner/repo/issues/567). Can also be a temporary_id from a previously created issue in the same workflow run \u2014 use the '#aw_abc123' form; the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'.", "x-synonyms": ["issueNumber"] }, "milestone_number": { @@ -837,7 +844,7 @@ }, "milestone_title": { "type": "string", - "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number — the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", + "description": "Milestone title to assign the issue to (e.g., \"v1.0\"). Used as an alternative to milestone_number \u2014 the handler looks up the milestone by title. Either milestone_number or milestone_title must be provided.", "x-synonyms": ["milestoneTitle"] }, "secrecy": { @@ -860,7 +867,7 @@ "properties": { "issue_number": { "type": ["number", "string"], - "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run — use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", + "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id from an issue created earlier in the same workflow run \u2014 use the '#aw_abc123' form (e.g., '#aw_Test123'); the bare 'aw_abc123' form is also accepted and normalised to '#aw_abc123'. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", "x-synonyms": ["issueNumber"] }, "pull_number": { @@ -1199,7 +1206,7 @@ }, "branch": { "type": "string", - "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD — only safe for single-PR workflows." + "description": "The local branch name that contains the committed changes to push (e.g., \"feature/my-fix\"). Providing this explicitly prevents race conditions in batch workflows where the working tree may have been checked out to a different PR's branch between commit and tool-call time. When omitted, the branch is inferred from the current git HEAD \u2014 only safe for single-PR workflows." }, "pull_request_number": { "type": ["number", "string"], @@ -1281,7 +1288,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' — for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", + "description": "Optional temporary identifier for this artifact upload. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_chart1', '#aw_img_out'. The bare 'aw_chart1' form is also accepted. Declare this ID here if you plan to embed the artifact URL in a subsequent message body using '#aw_ID' \u2014 for example '![chart](#aw_chart1)' in a create_discussion body. The safe-outputs processor replaces '#aw_ID' references with the actual artifact download URL after upload. When skip-archive is true the URL points directly to the file and is suitable for inline images.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -1314,7 +1321,7 @@ }, "body": { "type": "string", - "description": "Release body content in Markdown. Must be the final intended content — not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", + "description": "Release body content in Markdown. Must be the final intended content \u2014 not a placeholder or test value. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", "minLength": 20, "maxLength": 65536 }, @@ -1492,7 +1499,7 @@ }, { "name": "set_issue_field", - "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" — use the update_issue tool for those (for open/closed state, use update_issue.status).", + "description": "Set a single GitHub issue custom field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Does NOT support builtin issue fields such as \"title\", \"body\", or \"state\" \u2014 use the update_issue tool for those (for open/closed state, use update_issue.status).", "inputSchema": { "type": "object", "required": ["value"], @@ -1552,7 +1559,7 @@ "project": { "type": "string", "pattern": "^(https://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call — use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call \u2014 use '#aw_abc1' (canonical) or bare 'aw_abc1' (also accepted). Project names or numbers alone are NOT accepted." }, "operation": { "type": "string", @@ -1567,7 +1574,7 @@ }, "content_number": { "type": ["number", "string"], - "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call — use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call \u2014 use '#aw_abc123' (canonical); bare 'aw_abc123' is also accepted. Required when content_type is 'issue' or 'pull_request'.", "x-synonyms": ["contentNumber"] }, "target_repo": { @@ -1590,13 +1597,13 @@ "draft_issue_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Temporary ID of an existing draft issue to update — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", + "description": "Temporary ID of an existing draft issue to update \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted. Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates.", "x-synonyms": ["draftIssueId"] }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", + "description": "Unique temporary identifier for this draft issue. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. Provide this when creating a new draft to enable future updates via draft_issue_id.", "x-synonyms": ["temporaryId"] }, "fields": { @@ -1711,7 +1718,7 @@ }, { "name": "report_incomplete", - "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) — it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", + "description": "Signal that the task could not be completed due to an infrastructure or tool failure (e.g., MCP server crash, missing authentication, inaccessible repository). Use this when required tools or data are unavailable and the task cannot be meaningfully performed. This is distinct from noop (no action needed) \u2014 it indicates an active failure that prevented the task from running. Provide a specific reason and optional details so downstream issue aggregation can preserve complete incomplete-signal context. The workflow framework will treat this as a failure signal even when the agent exits successfully.", "inputSchema": { "type": "object", "required": ["reason"], @@ -1753,13 +1760,13 @@ "item_url": { "type": "string", "pattern": "^(https://github\\\\.com/[^/]+/[^/]+/issues/(\\\\d+|#?aw_[A-Za-z0-9_]{3,12})|#?aw_[A-Za-z0-9_]{3,12})$", - "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID — use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", + "description": "Optional GitHub issue URL or temporary ID to add as the first item to the project. Accepts either a full URL (e.g., 'https://github.com/owner/repo/issues/123'), a URL with temporary ID (e.g., 'https://github.com/owner/repo/issues/#aw_abc1'), or a plain temporary ID \u2014 use '#aw_abc1' (canonical); bare 'aw_abc1' is also accepted.", "x-synonyms": ["itemUrl"] }, "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", + "description": "Optional temporary identifier for this project. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_abc1', '#aw_pr_fix'. The bare 'aw_abc1' form is also accepted. If not provided, one will be auto-generated and returned in the response. Use this same '#aw_ID' form in add_project_item to reference this project.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -1903,7 +1910,7 @@ }, { "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 and is NOT accepted as a parameter — do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", + "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 and is NOT accepted as a parameter \u2014 do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", "inputSchema": { "type": "object", "required": ["conclusion", "title", "summary"], diff --git a/setup/js/start_mcp_gateway.cjs b/setup/js/start_mcp_gateway.cjs index a3f15ed7..1b522a74 100644 --- a/setup/js/start_mcp_gateway.cjs +++ b/setup/js/start_mcp_gateway.cjs @@ -101,6 +101,19 @@ function getJSONParseErrorContext(jsonText, parseErrorMessage) { return { line, column, lineText, key }; } +/** + * Repairs a known double-encoding regression where sink-visibility can be rendered as: + * "sink-visibility": ""public"" + * instead of: + * "sink-visibility": "public" + * + * @param {string} jsonText + * @returns {string} + */ +function normalizeSinkVisibilityEncoding(jsonText) { + return jsonText.replace(/("sink-visibility"\s*:\s*)""(public|private|internal)""/g, '$1"$2"'); +} + /** * Normalizes GH_AW_OTLP_IF_MISSING to a supported mode. * @param {string | undefined} value @@ -421,6 +434,11 @@ async function main() { } catch (err) { throw new Error(`Failed to read MCP configuration from stdin: ${String(err)}`, { cause: err }); } + const normalizedConfig = normalizeSinkVisibilityEncoding(mcpConfig); + if (normalizedConfig !== mcpConfig) { + core.warning("Detected double-encoded sink-visibility value in MCP config; applying compatibility normalization."); + mcpConfig = normalizedConfig; + } printTiming(configReadStart, "Configuration read from stdin"); core.info(""); @@ -1042,5 +1060,6 @@ module.exports = { hasNonEmptyOTLPHeaders, isOTLPIfMissingIgnore, getJSONParseErrorContext, + normalizeSinkVisibilityEncoding, resolveCopilotConfigPaths, }; diff --git a/setup/js/submit_pr_review.cjs b/setup/js/submit_pr_review.cjs index d9d8e8a0..d69ec229 100644 --- a/setup/js/submit_pr_review.cjs +++ b/setup/js/submit_pr_review.cjs @@ -84,6 +84,16 @@ async function main(config = {}) { } } + const pinnedCommitId = typeof config.commit_id === "string" ? config.commit_id.trim() : ""; + if (pinnedCommitId) { + core.info(`submit_pull_request_review: commit-id pinned to ${pinnedCommitId}`); + if (registry) { + registry.setDefaultPinnedCommitId(pinnedCommitId); + } else if (legacyBuffer && typeof legacyBuffer.setPinnedCommitId === "function") { + legacyBuffer.setPinnedCommitId(pinnedCommitId); + } + } + let processedCount = 0; /** diff --git a/setup/md/agent_failure_comment.md b/setup/md/agent_failure_comment.md index ae2a416f..dd68711b 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}{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}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_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}{missing_model_pricing_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_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 58081c4b..cc31d34d 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}{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}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_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}{missing_model_pricing_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_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/missing_model_pricing.md b/setup/md/missing_model_pricing.md new file mode 100644 index 00000000..fd81068a --- /dev/null +++ b/setup/md/missing_model_pricing.md @@ -0,0 +1,29 @@ +> [!WARNING] +> **Model has no AI credits pricing**: The agent failed because model `{model_name}` is not in the built-in pricing table and no default fallback pricing is configured. The AWF API proxy rejected every inference request with HTTP 400. + +This is a **configuration issue** — retrying will not help. The model must have pricing before the workflow can run. + +
+How to fix this + +**Option 1 — Add pricing in the workflow frontmatter:** + +{pricing_snippet} + +Use the provider key matching your engine: `github-copilot` (Copilot), `anthropic` (Claude), `openai` (Codex), or `google` (Gemini). Only `input` and `output` are required; the rest default to zero (or `output` for `reasoning`). + +**Option 2 — Map the model to a known model using the `models` field:** + +If `{model_name}` is an alias for a model already in the built-in pricing table, use the `models` frontmatter field to provide the mapping: + +```yaml +models: + {model_name_yaml_key}: + - claude-sonnet-4-5 +``` + +**Option 3 — Switch to a model already in the built-in pricing table:** + +Replace `{model_name}` in the workflow frontmatter with a model name that the AWF pricing system recognizes (e.g. `claude-sonnet-4-5`, `gpt-4.1`, `gemini-2.0-flash`). + +
diff --git a/setup/setup.sh b/setup/setup.sh index e9addfff..57731fca 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -365,6 +365,7 @@ SAFE_OUTPUTS_FILES=( "temporary_id.cjs" "invocation_context_helpers.cjs" "repo_memory_patch_size.cjs" + "data_schema_normalizer.cjs" ) SAFE_OUTPUTS_COUNT=0 diff --git a/setup/sh/convert_gateway_config_gemini.sh b/setup/sh/convert_gateway_config_gemini.sh index a104944d..92a5d878 100644 --- a/setup/sh/convert_gateway_config_gemini.sh +++ b/setup/sh/convert_gateway_config_gemini.sh @@ -86,12 +86,18 @@ echo "Target domain: $MCP_GATEWAY_HOST_DOMAIN:$MCP_GATEWAY_PORT" # 1. Remove "type" field (Gemini uses transport auto-detection from url/httpUrl) # 2. The "tools" field is preserved from the gateway config to enforce the tool allowlist # at the gateway layer (not removed, unlike older versions that treated it as Copilot-specific) -# 3. URLs must use localhost (MCP_GATEWAY_HOST_DOMAIN) since Gemini runs on the host runner +# 3. URLs must use MCP_GATEWAY_HOST_DOMAIN since Gemini runs on the host runner. +# Under normal conditions this is "localhost". Under network isolation it is the +# topology hostname (e.g. "awmg-mcpg") because Gemini honors HTTP_PROXY but ignores +# NO_PROXY, so a localhost URL would be tunneled through the egress proxy and denied. +# The topology hostname is already in the firewall allowlist via auto-allow-topology-hostnames. # Build the correct URL prefix using the host-side domain and port. # Gemini CLI runs directly on the host runner (not inside a Docker container), so use -# MCP_GATEWAY_HOST_DOMAIN (localhost) instead of MCP_GATEWAY_DOMAIN (host.docker.internal). +# MCP_GATEWAY_HOST_DOMAIN instead of MCP_GATEWAY_DOMAIN (host.docker.internal). # host.docker.internal does not resolve on the host runner on Linux. +# Under network isolation, MCP_GATEWAY_HOST_DOMAIN is set to the topology hostname +# (awmg-mcpg) rather than localhost — see writeMCPGatewayExports in mcp_setup_gateway.go. URL_PREFIX="http://${MCP_GATEWAY_HOST_DOMAIN}:${MCP_GATEWAY_PORT}" # Create .gemini directory in the workspace (project-level settings)