From 4f0aae11a8a74b300415f3f5ba8ec3442d0dbe90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:32:08 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.92.4 --- setup/js/add_mask_redaction.cjs | 334 +++++++++++++----- setup/js/agent_execution.cjs | 22 +- setup/js/ai_credits_context.cjs | 111 +++++- setup/js/checkout_pr_branch.cjs | 4 +- setup/js/collect_ndjson_output.cjs | 17 +- setup/js/create_prompt.cjs | 7 +- setup/js/empty_output_outcome.cjs | 37 +- setup/js/handle_agent_failure.cjs | 14 +- setup/js/harness_error_patterns.cjs | 121 +++++-- setup/js/json_string_scanner.cjs | 61 ++++ setup/js/mask_value_matcher.cjs | 132 +++++++ setup/js/package.json | 1 + setup/js/parse_mcp_gateway_log.cjs | 85 +++-- setup/js/redact_secrets.cjs | 139 ++++++-- setup/js/report_failed_jobs.cjs | 7 + setup/js/restore_aic_scan_cache.cjs | 38 +- setup/js/safe_output_redaction.cjs | 177 ++++++++++ setup/js/scripts/fixtures/safeoutputs.cjs | 26 ++ setup/js/scripts/test_safeoutputs_setup.mjs | 18 + setup/js/start_mcp_gateway.cjs | 73 ++-- setup/js/types/unified_session.d.ts | 9 + setup/js/unified_session.cjs | 38 +- setup/js/unified_session_payload.cjs | 1 + setup/js/unified_session_render.cjs | 3 + setup/js/vitest.config.mjs | 1 + .../js/work_queue_control_adapter_checks.cjs | 4 +- setup/js/workflow_job_durations.cjs | 96 +++++ setup/session_parsers.go | 3 +- setup/setup.sh | 8 + 29 files changed, 1356 insertions(+), 231 deletions(-) create mode 100644 setup/js/json_string_scanner.cjs create mode 100644 setup/js/mask_value_matcher.cjs create mode 100644 setup/js/safe_output_redaction.cjs create mode 100644 setup/js/scripts/fixtures/safeoutputs.cjs create mode 100644 setup/js/scripts/test_safeoutputs_setup.mjs create mode 100644 setup/js/workflow_job_durations.cjs diff --git a/setup/js/add_mask_redaction.cjs b/setup/js/add_mask_redaction.cjs index 6c2cde4b..fef182d1 100644 --- a/setup/js/add_mask_redaction.cjs +++ b/setup/js/add_mask_redaction.cjs @@ -14,8 +14,8 @@ /** Matches an `::add-mask::` workflow command and captures its payload. */ const ADD_MASK_COMMAND_RE = /::add-mask::(.*)$/; -/** Replacement used for masked values, matching the runner's own rendering. */ -const MASK_REPLACEMENT = "***"; +const { MASK_REPLACEMENT, redactMaskedValues, maskedValueMatches, overlappingMatchGroups } = require("./mask_value_matcher.cjs"); +const { transformJSONStrings } = require("./json_string_scanner.cjs"); /** * Decode a GitHub Actions workflow command payload. @@ -68,74 +68,206 @@ function collectAddMaskedValues(logContent) { } /** - * Replace all occurrences of the masked values with `***`. + * Remove `::add-mask::` command lines and redact every masked value from the text. * + * @param {string} text - Text about to be rendered into generated content + * @param {string[]} maskedValues - Values collected via {@link collectAddMaskedValues} + * @param {(value: string) => string} [replacementForValue] + * @returns {string} + */ +function applyAddMaskRedaction(text, maskedValues, replacementForValue) { + if (!text) return text; + const withoutCommands = text + .split("\n") + .filter(line => !isAddMaskCommandLine(line)) + .join("\n"); + return redactMaskedValues(withoutCommands, maskedValues, replacementForValue); +} + +/** + * Decode ambiguous escapes for matching only, mapping matches back to source + * spans so unrelated plain-text paths and literal escapes keep their bytes. * @param {string} text * @param {string[]} maskedValues + * @param {(value: string) => string} replacementForValue * @returns {string} */ -function redactMaskedValues(text, maskedValues) { - if (!text || !maskedValues || maskedValues.length === 0) return text; - const pending = []; - for (let order = 0; order < maskedValues.length; order++) { - const value = maskedValues[order]; - if (!value) continue; - const index = text.indexOf(value); - if (index !== -1) pending.push({ value, index, order }); - } - const precedes = (a, b) => a.index < b.index || (a.index === b.index && a.order < b.order); - const siftDown = start => { - let parent = start; - while (parent * 2 + 1 < pending.length) { - let child = parent * 2 + 1; - if (child + 1 < pending.length && precedes(pending[child + 1], pending[child])) child++; - if (!precedes(pending[child], pending[parent])) break; - [pending[parent], pending[child]] = [pending[child], pending[parent]]; - parent = child; +function redactAmbiguousMaskedValues(text, maskedValues, replacementForValue) { + const escapes = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + const pattern = /\\(["\\/bfnrt]|u[0-9a-fA-F]{4})/g; + const unescaped = text.replace(pattern, (_, escape) => { + if (escape[0] === "u") return String.fromCharCode(parseInt(escape.slice(1), 16)); + return escapes[escape]; + }); + let nextEscape = pattern.exec(text); + let offset = 0; + const sourceOffset = position => { + while (nextEscape && nextEscape.index - offset < position) { + offset += nextEscape[0].length - 1; + nextEscape = pattern.exec(text); } + return position + offset; }; - for (let i = Math.floor(pending.length / 2) - 1; i >= 0; i--) siftDown(i); const parts = []; let cursor = 0; - let replacements = 0; - while (pending.length && cursor < text.length) { - const next = pending[0]; - if (next.index >= cursor) { - if (next.index > cursor) { - if (replacements) parts.push(MASK_REPLACEMENT.repeat(replacements)); - replacements = 0; - parts.push(text.slice(cursor, next.index)); - } - replacements++; - cursor = next.index + next.value.length; - } - // Only refresh consumed or overlapping occurrences; later candidates stay cached. - next.index = text.indexOf(next.value, cursor); - if (next.index === -1) { - const last = pending.pop(); - if (pending.length) pending[0] = last; - } - siftDown(0); + for (const match of maskedValueMatches(unescaped, maskedValues)) { + const start = sourceOffset(match.start); + const end = sourceOffset(match.end); + parts.push(text.slice(cursor, start), replacementForValue(match.value)); + cursor = end; } - if (replacements) parts.push(MASK_REPLACEMENT.repeat(replacements)); - parts.push(text.slice(cursor)); - return parts.join(""); + // Ambiguous records retain their JSON-string wrapper, but its decoded value + // now changes only at the source spans that actually matched a mask. + return cursor ? JSON.stringify(parts.join("") + text.slice(cursor)) : text; } /** - * Remove `::add-mask::` command lines and redact every masked value from the text. - * - * @param {string} text - Text about to be rendered into generated content - * @param {string[]} maskedValues - Values collected via {@link collectAddMaskedValues} + * @param {string} encoded * @returns {string} */ -function applyAddMaskRedaction(text, maskedValues) { - if (!text) return text; - const withoutCommands = text - .split("\n") - .filter(line => !isAddMaskCommandLine(line)) - .join("\n"); - return redactMaskedValues(withoutCommands, maskedValues); +function decodeJSONString(encoded) { + try { + return JSON.parse(encoded); + } catch (error) { + throw new Error("Unable to decode JSON log string during secret redaction", { cause: error }); + } +} + +/** + * Resolve raw and encoded matches together before a malformed record can be + * rewritten. Quoted-token interiors use decoded semantics; raw matches spanning + * token boundaries remain eligible so a shorter mask cannot fragment them. + * @param {string} text + * @param {string[]} maskedValues + * @param {(value: string) => string} replacementForValue + * @param {boolean} [matchRawText] - Include raw matches inside quoted spans of invalid artifacts + * @returns {string} + */ +function redactMixedMaskedValues(text, maskedValues, replacementForValue, matchRawText = false) { + const commandLines = new Set(); + if (matchRawText) { + const lines = text.split("\n"); + for (let index = 0; index < lines.length; index++) { + if (isAddMaskCommandLine(lines[index])) commandLines.add(lines[index] + (index < lines.length - 1 ? "\n" : "")); + } + maskedValues = [...maskedValues, ...commandLines]; + } + maskedValues = [...maskedValues].sort((a, b) => b.length - a.length); + const tokens = []; + let scanned = 0; + transformJSONStrings( + text, + token => { + decodeJSONString(token); + tokens.push(scanned, scanned + token.length); + scanned += token.length; + return token; + }, + plain => { + scanned += plain.length; + return plain; + } + ); + const tokenAt = position => { + let low = 0; + let high = tokens.length / 2; + while (low < high) { + const mid = (low + high) >>> 1; + if (tokens[mid * 2] <= position) low = mid + 1; + else high = mid; + } + return (low - 1) * 2; + }; + const escapeStarts = []; + const escapeOffsets = []; + const escapes = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + let offset = 0; + const unescaped = text.replace(/\\(["\\/bfnrt]|u[0-9a-fA-F]{4})/g, (match, escape, index) => { + escapeStarts.push(index - offset); + offset += match.length - 1; + escapeOffsets.push(offset); + return escape[0] === "u" ? String.fromCharCode(parseInt(escape.slice(1), 16)) : escapes[escape]; + }); + const sourceOffset = position => { + let low = 0; + let high = escapeStarts.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (escapeStarts[mid] < position) low = mid + 1; + else high = mid; + } + return position + (low ? escapeOffsets[low - 1] : 0); + }; + const toSource = match => ({ start: sourceOffset(match.start), end: sourceOffset(match.end), value: match.value }); + const rawMatches = maskedValueMatches( + text, + maskedValues, + match => { + if (matchRawText) return true; + const token = tokenAt(match.start); + return token < 0 || match.end > tokens[token + 1]; + }, + true + ); + const decodedMatches = maskedValueMatches( + unescaped, + maskedValues, + match => { + const source = toSource(match); + const token = tokenAt(source.start); + return token < 0 || source.end > tokens[token + 1] || (source.start > tokens[token] && source.end < tokens[token + 1]); + }, + true + ); + function* sourceMatches() { + let raw = rawMatches.next(); + let decoded = decodedMatches.next(); + let decodedSource = decoded.done ? undefined : toSource(decoded.value); + while (!raw.done || !decoded.done) { + const useRaw = !decodedSource || (!raw.done && (raw.value.start < decodedSource.start || (raw.value.start === decodedSource.start && raw.value.end >= decodedSource.end))); + const match = useRaw && !raw.done ? raw.value : decodedSource; + if (!match) throw new Error("Unable to select log spans during secret redaction"); + const token = tokenAt(match.start); + const insideToken = token >= 0 && match.start > tokens[token] && match.end < tokens[token + 1]; + const overlapsToken = (token >= 0 && match.start < tokens[token + 1]) || (token + 2 < tokens.length && tokens[token + 2] < match.end); + yield { ...match, ambiguous: !insideToken && (overlapsToken || text.slice(match.start, match.end) !== match.value) }; + if (useRaw) raw = rawMatches.next(); + else { + decoded = decodedMatches.next(); + decodedSource = decoded.done ? undefined : toSource(decoded.value); + } + } + } + const parts = []; + let cursor = 0; + let ambiguous = false; + let repeated = ""; + let repetitions = 0; + for (const match of overlappingMatchGroups(sourceMatches())) { + const token = tokenAt(match.start); + const insideToken = token >= 0 && match.start > tokens[token] && match.end < tokens[token + 1]; + const wholeLine = commandLines.has(match.value) || ((match.start === 0 || text[match.start - 1] === "\n") && (match.end === text.length || text[match.end] === "\n") && maskedValues.includes(text.slice(match.start, match.end))); + ambiguous ||= match.ambiguous && !wholeLine; + const marker = commandLines.has(match.value) ? "" : replacementForValue(match.value); + const replacement = insideToken ? JSON.stringify(marker).slice(1, -1) : marker; + if (match.start > cursor || replacement !== repeated) { + if (repetitions) parts.push(repeated.repeat(repetitions)); + repetitions = 0; + if (match.start > cursor) parts.push(text.slice(cursor, match.start)); + } + repeated = replacement; + repetitions++; + cursor = match.end; + } + if (repetitions) parts.push(repeated.repeat(repetitions)); + parts.push(text.slice(cursor)); + const redacted = transformJSONStrings(parts.join(""), encoded => { + const value = decodeJSONString(encoded); + const withoutCommands = applyAddMaskRedaction(value, []); + return withoutCommands === value ? encoded : JSON.stringify(withoutCommands); + }); + // A registered raw whole-line value removes all of the original syntax. + return ambiguous && !maskedValues.includes(text) ? JSON.stringify(redacted) : redacted; } /** @@ -145,32 +277,79 @@ function applyAddMaskRedaction(text, maskedValues) { * byte-for-byte. * @param {string} content * @param {string[]} maskedValues + * @param {(value: any) => any} [redactJSONValue] - Structured protocol redactor for valid JSON records + * @param {(value: string) => string} [replacementForValue] - Marker for each matched registered value + * @param {{textMode?: boolean}} [options] - Match invalid artifacts as raw and escaped text without splitting multiline secrets * @returns {string} */ -function redactArtifactMaskedValues(content, maskedValues) { - if (!content || !maskedValues.length) return content; +function redactArtifactMaskedValues(content, maskedValues, redactJSONValue, replacementForValue, options = {}) { + if (!content || (!maskedValues.length && !redactJSONValue)) return content; + const preserveLexemes = !!replacementForValue && !redactJSONValue; + const replacement = replacementForValue || (() => MASK_REPLACEMENT); + if (options.textMode && preserveLexemes) { + // Only multiline credentials can overlap a removed command-line boundary. + if (!maskedValues.some(value => /[\r\n]/.test(value))) { + content = content + .split("\n") + .filter(line => !isAddMaskCommandLine(line)) + .join("\n"); + } + return redactMixedMaskedValues(content, maskedValues, replacement, true); + } const redactValue = value => { - if (typeof value === "string") return applyAddMaskRedaction(value, maskedValues); + if (typeof value === "string") return applyAddMaskRedaction(value, maskedValues, replacementForValue); if (Array.isArray(value)) return value.map(redactValue); - if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, nested]) => [redactMaskedValues(key, maskedValues), redactValue(nested)])); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, nested]) => [redactMaskedValues(key, maskedValues, replacementForValue), redactValue(nested)])); return value; }; + const redactString = (encoded, isKey = false) => { + const value = decodeJSONString(encoded); + const redacted = isKey ? redactMaskedValues(value, maskedValues, replacementForValue) : redactValue(value); + return redacted === value ? encoded : JSON.stringify(redacted); + }; const redactJson = text => { + let value; try { - let value = JSON.parse(text); - // Skip quoted strings and match original numeric tokens at every depth so - // rounding or exponent notation cannot hide a mask. - const numericRedacted = text.replace(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, token => { - if (token.startsWith('"')) return token; - const redacted = redactMaskedValues(token, maskedValues); - return redacted === token ? token : JSON.stringify(redacted); - }); - if (numericRedacted !== text) value = JSON.parse(numericRedacted); - const redacted = JSON.stringify(redactValue(value)); - return redacted === JSON.stringify(value) ? numericRedacted : text.match(/^\s*/)[0] + redacted + text.match(/\s*$/)[0]; + value = JSON.parse(text); } catch { return undefined; } + // Skip quoted strings and match original numeric tokens at every depth so + // rounding or exponent notation cannot hide a mask. + const numericRedacted = transformJSONStrings( + text, + token => token, + plain => + plain.replace(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, token => { + const redacted = redactMaskedValues(token, maskedValues, replacementForValue); + return redacted === token ? token : JSON.stringify(redacted); + }) + ); + if (numericRedacted !== text) { + try { + value = JSON.parse(numericRedacted); + } catch (error) { + throw new Error("Unable to decode redacted JSON log record during secret redaction", { cause: error }); + } + } + if (preserveLexemes) { + let cursor = 0; + return transformJSONStrings( + numericRedacted, + token => { + cursor += token.length; + let next = cursor; + while (next < numericRedacted.length && " \t\r\n".includes(numericRedacted[next])) next++; + return redactString(token, numericRedacted[next] === ":"); + }, + plain => { + cursor += plain.length; + return plain; + } + ); + } + const redacted = JSON.stringify((redactJSONValue || redactValue)(value)); + return redacted === JSON.stringify(value) ? numericRedacted : text.match(/^\s*/)[0] + redacted + text.match(/\s*$/)[0]; }; const json = redactJson(content); if (json !== undefined) return json; @@ -180,17 +359,11 @@ function redactArtifactMaskedValues(content, maskedValues) { .map(line => { const jsonLine = redactJson(line); if (jsonLine !== undefined) return jsonLine; + if (preserveLexemes) return redactMixedMaskedValues(line, maskedValues, replacement); // Mixed or malformed logs can still contain recoverable JSON strings. - const decoded = line.replace(/"(?:[^"\\]|\\.)*"/g, encoded => { - try { - const value = JSON.parse(encoded); - const redacted = redactValue(value); - return redacted === value ? encoded : JSON.stringify(redacted); - } catch { - return encoded; - } - }); - return redactMaskedValues(decoded, maskedValues); + const decoded = transformJSONStrings(line, redactString); + const redacted = redactMaskedValues(decoded, maskedValues, replacementForValue); + return redactAmbiguousMaskedValues(redacted, maskedValues, replacement); }) .join("\n"); } @@ -203,5 +376,6 @@ module.exports = { isAddMaskCommandLine, redactArtifactMaskedValues, redactMaskedValues, + transformJSONStrings, unescapeWorkflowCommandValue, }; diff --git a/setup/js/agent_execution.cjs b/setup/js/agent_execution.cjs index de9b38b1..badc805f 100644 --- a/setup/js/agent_execution.cjs +++ b/setup/js/agent_execution.cjs @@ -1,7 +1,7 @@ // @ts-check const { detectErrors, buildOutputLines, isCAPIServerError } = require("./agent_error_patterns.cjs"); -const { detectNonRetryableHarnessGuard, isAuthenticationFailedError } = require("./harness_error_patterns.cjs"); +const { API_PROXY_GUARD_REJECTION_PATTERNS, detectNonRetryableHarnessGuard, isAuthenticationFailedError, selectNativeErrorDiagnostic, shouldSuppressGuardCategory } = require("./harness_error_patterns.cjs"); const { crashSignalNameForExitCode } = require("./harness_crash_signals.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); const { sessionContext } = require("./agent_session.cjs"); @@ -243,11 +243,15 @@ function agentErrorSummaryText(content, eventsOrSourceEngine = [], rawSourceEngi */ function collectNativeErrorEvidence(errors) { const diagnostics = []; + const guardDiagnostics = []; const codes = new Set(); const types = new Set(); for (const error of errors) { - diagnostics.push(JSON.stringify(error)); - const queue = [error]; + const diagnostic = selectNativeErrorDiagnostic(error); + const diagnosticText = JSON.stringify(diagnostic) ?? ""; + diagnostics.push(diagnosticText); + guardDiagnostics.push(diagnosticText); + const queue = [diagnostic]; for (let visited = 0; visited < 16 && queue.length; visited++) { const value = queue.shift(); if (typeof value === "string") { @@ -274,10 +278,9 @@ function collectNativeErrorEvidence(errors) { for (const key of ["error", "message", "errorMessage"]) { if (value[key] !== undefined) queue.push(value[key]); } - if (value.metadata?.raw !== undefined) queue.push(value.metadata.raw); } } - return { diagnostics, codes, types }; + return { diagnostics, guardDiagnostics, codes, types }; } /** @@ -290,7 +293,7 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi const categorySet = new Set(categories); const { diagnostics, errors: rawErrors } = collectAgentErrorEvidence(content, events, rawSourceEngine); const errors = [...events.flatMap(recordErrors), ...rawErrors]; - const { diagnostics: nativeDiagnostics, codes, types } = collectNativeErrorEvidence(errors); + const { diagnostics: nativeDiagnostics, guardDiagnostics, codes, types } = collectNativeErrorEvidence(errors); let observedExit = exitCode; for (const data of observations) { validateAgentExecution(data); @@ -305,6 +308,7 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi const terminal = line.match(/^\[(?:copilot|claude|codex)-harness\].*?\bdone: exitCode=(\d+)\b/); if (terminal && exitCode === undefined && !observations.some(data => data.exitCode !== undefined)) observedExit = Number(terminal[1]); } + guardDiagnostics.push(...diagnostics); diagnostics.push(...nativeDiagnostics); const text = diagnostics.join("\n"); for (const diagnostic of diagnostics) { @@ -322,6 +326,12 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi if (guards.awfAPIProxyBlockingRequests) categorySet.add("awf_api_proxy_blocking_requests"); if (guards.goalAlreadyActive) categorySet.add("goal_already_active"); if (guards.apiProxyGuardRejection) categorySet.add(guards.apiProxyGuardRejection.guard); + // Observation accepts attributed diagnostic text; retry decisions require transport envelopes. + for (const diagnostic of guardDiagnostics) { + for (const { guard, pattern } of API_PROXY_GUARD_REJECTION_PATTERNS) { + if (pattern.test(diagnostic) && !shouldSuppressGuardCategory(guard, diagnostic)) categorySet.add(guard); + } + } const crashSignal = observedExit === undefined ? null : crashSignalNameForExitCode(observedExit); if (crashSignal) { categorySet.add("sandbox_runtime_crash"); diff --git a/setup/js/ai_credits_context.cjs b/setup/js/ai_credits_context.cjs index 9d383891..35e90196 100644 --- a/setup/js/ai_credits_context.cjs +++ b/setup/js/ai_credits_context.cjs @@ -3,6 +3,7 @@ require("./shim.cjs"); const fs = require("fs"); const path = require("path"); +const { parseAICreditsExceededProxyRejection } = require("./harness_error_patterns.cjs"); const MAX_AI_CREDITS_FIELDS = new Set(["max_ai_credits", "maxAiCredits"]); const AI_CREDITS_FIELDS = new Set(["ai_credits", "aiCredits"]); @@ -32,10 +33,88 @@ const API_PROXY_GUARD_REJECTION_EVENT_TYPES = [MAX_CACHE_MISSES_EXCEEDED_EVENT_T // Counter fields carried by proxy guard rejection events, reported alongside the guard name // so the step log states what limit was hit and at which value. const API_PROXY_GUARD_COUNTER_FIELDS = ["consecutive_cache_misses", "max_cache_misses", "effective_tokens", "max_effective_tokens", "permission_denied_count", "max_permission_denied", "model"]; -const MAX_AI_CREDITS_EXCEEDED_STDIO_RE = /maximum ai credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?/i; +// Raw stderr fallback assumes unprefixed subprocess diagnostics; only explicitly allowlisted wrappers are unwrapped below. +const MAX_AI_CREDITS_EXCEEDED_STDIO_RE = + /^(?:(?:Execution failed:\s*)?CAPIError:\s*429\s+|Failed to get response from the AI model; retried \d+ times(?: \(total retry wait time:[^)]+\))?(?: \(Request-ID [^)]+\))?\.? Last error: CAPIError:\s*429\s+|Fatal:\s*)Maximum AI credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?\.?$/i; +const MAX_AI_CREDITS_EXCEEDED_NATIVE_ERROR_RE = /^(?:(?:API Error:\s*)?403(?::\s*|\s+)(?:Forbidden[.:]?\s*)?)?Maximum AI credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?\.?$/i; const DEFAULT_AGENT_STDIO_LOG = "/tmp/gh-aw/agent-stdio.log"; const AGENT_STDIO_LOG_MAX_TAIL = 64 * 1024; // 64 KB — sufficient for any realistic error block +/** + * Compatibility for native diagnostics alongside the pair-only harness parser. + * Never walk arbitrary JSON fields: only engine-owned error slots are eligible. + * @param {string} line + * @returns {{ aiCredits: string, maxAICredits: string } | null} + */ +function parseNativeAICreditsRejection(line) { + let diagnostic = line; + let nativeEnvelope = false; + if (line.trimStart().startsWith("{")) { + let record; + try { + record = JSON.parse(line); + } catch { + return null; + } + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + if (record.role || record.data?.role || record.error?.role || record.parentToolUseId || record.parent_tool_use_id || record.parentToolCallId || record.agentId || record.data?.parentToolCallId || record.data?.agentId) return null; + if (["warning", "info"].includes(record.data?.severity || record.severity)) return null; + if (record.message?.role && !["message_end", "turn_end", "assistant"].includes(record.type)) return null; + if (record.message?.role && record.message.role !== "assistant") return null; + const rejection = parseAICreditsExceededProxyRejection(line); + if (rejection) return { aiCredits: String(rejection.aiCredits), maxAICredits: String(rejection.maxAICredits) }; + if (["message_end", "turn_end"].includes(record.type) && record.message?.role === "assistant" && record.message.stopReason === "error") { + diagnostic = record.message.errorMessage; + } else if (record.type === "error" && typeof record.error === "string" && record.message === undefined) { + diagnostic = record.error; + } else if (record.type === "session.error" && record.data) { + diagnostic = record.data.message; + } else { + return null; + } + nativeEnvelope = true; + } else { + const sdkError = /^\[(?:copilot-sdk-driver|sdk-driver)\]\s+(?:\[sdk-driver\]\s+)?(?:error|unhandled error):\s*(?:Error:\s*)?([\s\S]*)$/i.exec(line); + if (sdkError) { + diagnostic = sdkError[1]; + nativeEnvelope = true; + } + // Pi strips the HTTP status from its JSON-quoted error field. + const providerError = /^\[gh-aw\/pi-provider\]\s+\S+\s+provider_error provider=.+? model=.+? api=\S+ status=403 method=\S+ url=.+? response_headers=\S+ error=("(?:[^"\\]|\\.)*")$/.exec(line); + if (providerError) { + try { + diagnostic = JSON.parse(providerError[1]); + } catch { + return null; + } + nativeEnvelope = true; + } + } + if (typeof diagnostic !== "string") return null; + let match = MAX_AI_CREDITS_EXCEEDED_STDIO_RE.exec(diagnostic); + if (!match && nativeEnvelope) { + // Goose's terminal error wraps the response and appends fixed retry advice. + diagnostic = diagnostic.replace(/^Ran into this error: Authentication error: Status: 403 Forbidden\. Response: /, "").replace(/\n\nPlease retry if you think this is a transient or recoverable error\.$/, ""); + // Pi may retain the provider's JSON error body after the HTTP status. + const jsonBody = /^(?:403(?::\s*|\s+))?(\{.*\})$/.exec(diagnostic); + if (jsonBody) { + try { + const body = JSON.parse(jsonBody[1]); + if (body.role || body.error?.role || ["assistant", "user", "tool", "tool_result", "toolResponse", "message"].includes(body.type)) return null; + diagnostic = typeof body.error?.message === "string" ? body.error.message : body.message; + } catch { + return null; + } + } + match = typeof diagnostic === "string" ? MAX_AI_CREDITS_EXCEEDED_NATIVE_ERROR_RE.exec(diagnostic) : null; + } + if (!match) return null; + const aiCredits = match[1] || ""; + const maxAICredits = match[2] || ""; + if (aiCredits && (!Number.isFinite(Number(aiCredits)) || !Number.isFinite(Number(maxAICredits)) || Number(maxAICredits) <= 0 || Number(aiCredits) < Number(maxAICredits))) return null; + return { aiCredits, maxAICredits }; +} + /** * @param {unknown} value * @returns {string} @@ -716,25 +795,21 @@ function parseAICreditsExceededFromAgentStdio() { fs.closeSync(fd); } const content = buf.toString("utf8"); - const proxyHTTP403 = content.split(/\r?\n/).some(line => { - const proxyFailure = line.match(/Authentication failed with provider at (\S+) \(HTTP 403\)\.?/i); + const lines = content.split(/\r?\n/); + const proxyHTTP403 = lines.some(line => { + const proxyFailure = line.match(/^Authentication failed with provider at (\S+) \(HTTP 403\)\.?$/i); return proxyFailure !== null && isLikelyAWFAPIProxyURL(proxyFailure[1]); }); - // Use matchAll and take the last occurrence — in retried runs the final - // entry carries the authoritative (highest) credit values. - const RE_G = new RegExp(MAX_AI_CREDITS_EXCEEDED_STDIO_RE.source, "gi"); - const allMatches = [...content.matchAll(RE_G)]; - const match = allMatches.at(-1); - if (!match) return { ...EMPTY_AGENT_STDIO_STATE, proxyHTTP403 }; - const aiCredits = parsePositiveNumberString(match[1] || ""); - const maxAICredits = parsePositiveNumberString(match[2] || ""); - return { - aiCredits, - maxAICredits, - rateLimitError: true, - maxAICreditsExceeded: true, - proxyHTTP403, - }; + // JSON tool/assistant records can quote rejection examples. Only inspect + // authenticated transport errors or a complete native stderr diagnostic. + let result = { ...EMPTY_AGENT_STDIO_STATE, proxyHTTP403 }; + for (const line of lines) { + const rejection = parseNativeAICreditsRejection(line); + if (!rejection) continue; + const { aiCredits, maxAICredits } = rejection; + result = { aiCredits, maxAICredits, rateLimitError: true, maxAICreditsExceeded: true, proxyHTTP403 }; + } + return result; } catch { return EMPTY_AGENT_STDIO_STATE; } diff --git a/setup/js/checkout_pr_branch.cjs b/setup/js/checkout_pr_branch.cjs index 92422d5b..76c8ad0c 100644 --- a/setup/js/checkout_pr_branch.cjs +++ b/setup/js/checkout_pr_branch.cjs @@ -36,6 +36,7 @@ const { detectForkPR } = require("./pr_helpers.cjs"); const { parseAllowedBots, isAllowedBot, canonicalizeBotIdentifier } = require("./check_permissions_utils.cjs"); const { ERR_API, ERR_PERMISSION } = require("./error_codes.cjs"); const TRUSTED_CHECKOUT_PERMISSIONS = ["write", "maintain", "admin"]; +const TRUSTED_BOT_PR_ACTIONS = ["opened", "synchronize", "ready_for_review", "reopened"]; // Centralized command/label routing uses the repository GITHUB_TOKEN, whose // GitHub-authenticated actor is fixed to this platform identity. const CENTRALIZED_ROUTER_ACTOR = "github-actions[bot]"; @@ -295,7 +296,8 @@ async function assertTrustedCheckoutRuntime(awContext, pullRequest) { // events require the collaborator check unless explicitly allow-listed below. if ( (context.eventName === "pull_request" || context.eventName === "pull_request_target") && - (context.payload.action === "opened" || context.payload.action === "synchronize") && + typeof context.payload.action === "string" && + TRUSTED_BOT_PR_ACTIONS.includes(context.payload.action) && senderType === "Bot" && context.payload.sender?.login === actor && context.payload.repository !== undefined && diff --git a/setup/js/collect_ndjson_output.cjs b/setup/js/collect_ndjson_output.cjs index 8b234c3b..90f3f9c4 100644 --- a/setup/js/collect_ndjson_output.cjs +++ b/setup/js/collect_ndjson_output.cjs @@ -9,7 +9,7 @@ const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = require("./constants.cjs"); const { ERR_API, ERR_PARSE } = require("./error_codes.cjs"); const { parseIntTemplatable } = require("./templatable.cjs"); const { isProbingNoopMessage } = require("./intent_probe.cjs"); -const { buildEmptyOutputOutcome } = require("./empty_output_outcome.cjs"); +const { buildEmptyOutputOutcome, collectOutcomeSession, hasFailedFinalResult } = require("./empty_output_outcome.cjs"); const MENTION_AWARE_OUTPUT_TYPES = new Set(["add_comment", "close_discussion", "create_discussion", "create_issue", "create_pull_request", "create_pull_request_review_comment", "reply_to_pull_request_review_comment"]); @@ -374,8 +374,19 @@ async function main() { let collectorDriverExitCode; let collectorRetryCount; let collectorEngineErrorType; - if (!queueScope && !parsedItems.some(item => !["missing_tool", "missing_data"].includes(item.type))) { - const incompleteOutcome = buildEmptyOutputOutcome(errors); + const hasTerminalOutput = parsedItems.some(item => !["missing_tool", "missing_data"].includes(item.type)); + let failedFinalResult = false; + let session; + if (!queueScope && hasTerminalOutput) { + try { + session = collectOutcomeSession(TMP_GH_AW_PATH); + failedFinalResult = hasFailedFinalResult(session.events); + } catch { + core.warning("Runtime diagnostics could not be collected."); + } + } + if (!queueScope && (failedFinalResult || !hasTerminalOutput)) { + const incompleteOutcome = buildEmptyOutputOutcome(errors, undefined, session); parsedItems.push(incompleteOutcome); collectorEmptyOutputCause = incompleteOutcome.reason; collectorFailureCause = incompleteOutcome.failureCause; diff --git a/setup/js/create_prompt.cjs b/setup/js/create_prompt.cjs index e7822935..fba65ad7 100644 --- a/setup/js/create_prompt.cjs +++ b/setup/js/create_prompt.cjs @@ -162,14 +162,15 @@ function renderWorkClaim(payload) { return ""; } - return assignment.claims + const assignmentJSON = JSON.stringify({ work_queue_assignment: assignment }).replace(/ { - const work = JSON.stringify({ id: member.work_id, payload: member.work, result_refs: member.result_refs }).replace(/\n${work}\n${claim}\nWhen the work is complete, call work_queue_claim_finish with outcome "completed" and claim_handle ${handle} to record the finish intent. Set keep_open: true only with completed to finish this Claim while retaining the Work in the queue and leaving its backing Issue open; the Work can be claimed again only after delivery is verified. keep_open defaults to false. If you cannot complete the work, call it with outcome "cancelled" and the same claim_handle. Always include this claim_handle on safe outputs and queue-control calls. The claim is only complete after trusted reconciliation verifies it; recording intent alone does not authorize safe outputs.\n\n`; + return `\n${claim}\nWhen the work is complete, call work_queue_claim_finish with outcome "completed" and claim_handle ${handle} to record the finish intent. Set keep_open: true only with completed to finish this Claim while retaining the Work in the queue and leaving its backing Issue open; the Work can be claimed again only after delivery is verified. keep_open defaults to false. If you cannot complete the work, call it with outcome "cancelled" and the same claim_handle. Always include this claim_handle on safe outputs and queue-control calls. The claim is only complete after trusted reconciliation verifies it; recording intent alone does not authorize safe outputs.\n\n`; }) .join(""); + return `\n${assignmentJSON}\n\n${claims}`; } /** diff --git a/setup/js/empty_output_outcome.cjs b/setup/js/empty_output_outcome.cjs index 8807e49b..1e44098d 100644 --- a/setup/js/empty_output_outcome.cjs +++ b/setup/js/empty_output_outcome.cjs @@ -4,7 +4,7 @@ const fs = require("fs"); const path = require("path"); const { collectUnifiedSession } = require("./unified_session.cjs"); const { agentErrorSummaryText } = require("./agent_execution.cjs"); -const { sessionContext } = require("./agent_session.cjs"); +const { sessionContext, isNestedSessionEvent } = require("./agent_session.cjs"); const { collectArtifactSecretValues } = require("./safe_output_manifest.cjs"); const { collectAddMaskedValues } = require("./add_mask_redaction.cjs"); const { redactDiagnosticText, redactAndBoundDiagnostics } = require("./diagnostic_sanitization.cjs"); @@ -138,11 +138,29 @@ function buildCompletionEvidence(rootDir = "/tmp/gh-aw") { return summary; } +/** + * @param {string} [rootDir] + * @returns {ReturnType} + */ +function collectOutcomeSession(rootDir = "/tmp/gh-aw") { + return collectUnifiedSession({ rootDir, ...(process.env.GH_AW_ENGINE_ID === "aider" ? { engine: "aider" } : {}), warn: () => {} }); +} + +/** @param {import("./types/unified_session").UnifiedSession} events @returns {boolean} */ +function hasFailedFinalResult(events) { + const workflowInfo = events.find(event => event.type === "workflow.info" && event.provenance.component === "workflow"); + const engineId = process.env.GH_AW_ENGINE_ID ?? workflowInfo?.data.engineId ?? workflowInfo?.data.engine_id; + if (typeof engineId !== "string" || !["aider", "gemini"].includes(engineId)) return false; + const result = events.filter(event => event.type === "session.result" && event.provenance.component === "agent" && event.provenance.phase === "agent" && !isNestedSessionEvent(event)).at(-1); + return Array.isArray(result?.data.errors) && result.data.errors.length > 0; +} + /** * Silence is not evidence of an intentional noop. Preserve runtime diagnostics * in a first-class incomplete signal when the agent emitted no valid outputs. * @param {string[]} errors * @param {string} [rootDir] + * @param {ReturnType} [session] * @returns {{ * type: string, * reason: string, @@ -153,17 +171,19 @@ function buildCompletionEvidence(rootDir = "/tmp/gh-aw") { * details?: string * }} */ -function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { +function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw", session) { const diagnostics = new Set(); if (errors.length) diagnostics.add("Safe-output validation status: failed"); let reason = errors.length ? "invalid_safe_outputs" : "missing_terminal_safe_output"; let events = []; let maskedValues = []; try { - ({ events, maskedValues } = collectUnifiedSession({ rootDir, ...(process.env.GH_AW_ENGINE_ID === "aider" ? { engine: "aider" } : {}), warn: () => {} })); + ({ events, maskedValues } = session ?? collectOutcomeSession(rootDir)); } catch { diagnostics.add("Runtime diagnostics could not be collected."); } + const failedFinalResult = hasFailedFinalResult(events); + if (failedFinalResult) reason = "engine_driver_failure"; let stdio = ""; try { const stdioPath = path.join(rootDir, "agent-stdio.log"); @@ -184,7 +204,7 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { const entry = JSON.parse(line); if (["parse_args_error", "unrecognized_args"].includes(entry?.event)) { safeoutputsCliError = true; - reason = "safeoutputs_cli_error"; + if (!failedFinalResult) reason = "safeoutputs_cli_error"; diagnostics.add("The safeoutputs CLI failed. Run `safeoutputs --help` and pass a single quoted JSON object or --key value flags."); } } catch { @@ -339,7 +359,9 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { ] : [ `Failure classification: ${failureCause}`, - reason === "engine_driver_failure" ? `Driver exit code: ${driverExitCode ?? "not recorded"}. The engine driver exited before a terminal safe output was recorded.` : `Driver exit code: ${driverExitCode ?? "not recorded"}`, + reason === "engine_driver_failure" && !failedFinalResult + ? `Driver exit code: ${driverExitCode ?? "not recorded"}. The engine driver exited before a terminal safe output was recorded.` + : `Driver exit code: ${driverExitCode ?? "not recorded"}`, `Last tool call status: ${lastToolCallStatus}`, ...(lastToolExitCode !== undefined ? [`Last tool exit code: ${lastToolExitCode}`] : []), `Failed tool calls observed: ${failedToolCalls}`, @@ -348,7 +370,8 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { ...completionEvidence, ]; if (failureCause === "missing_terminal_safe_output" && !observedCompletionEvidence.length) summary.splice(-1, 0, "Safe-output telemetry: not recorded"); - if (!agentNotStarted && failureCause !== "missing_terminal_safe_output") diagnostics.add("Agent finished without emitting a terminal safe output; task completion could not be confirmed."); + if (failedFinalResult) diagnostics.add("The engine's final result reported an error; task completion could not be confirmed, even if safe outputs were recorded."); + else if (!agentNotStarted && failureCause !== "missing_terminal_safe_output") diagnostics.add("Agent finished without emitting a terminal safe output; task completion could not be confirmed."); if (errorCodes.length) diagnostics.add(`Engine HTTP error codes: ${[...new Set(errorCodes)].join(", ")}`); if (permissionDenials) diagnostics.add(`Permission denials observed: ${permissionDenials}`); if (toolDenialLimitExceeded) diagnostics.add("Tool denial limit status: exceeded"); @@ -371,4 +394,4 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { }; } -module.exports = { buildEmptyOutputOutcome, buildCompletionEvidence, EMPTY_OUTPUT_CAUSES, EMPTY_OUTPUT_FAILURE_CAUSES }; +module.exports = { buildEmptyOutputOutcome, buildCompletionEvidence, collectOutcomeSession, hasFailedFinalResult, EMPTY_OUTPUT_CAUSES, EMPTY_OUTPUT_FAILURE_CAUSES }; diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index f3697ff1..4d9c3d84 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -4162,6 +4162,8 @@ async function main() { agentOutputResult = { success: false, items: [] }; } const taskCompleteRegistrationIssueOnlyReportIncomplete = agentOutputResult.success && shouldIgnoreTaskCompleteRegistrationReports(agentOutputResult.items); + const collectorEmptyOutputCause = agentOutputResult.success ? agentOutputResult.collectorEmptyOutputCause : undefined; + const isCancelledMissingTerminalOutput = agentConclusion === "cancelled" && collectorEmptyOutputCause === "missing_terminal_safe_output"; if (agentConclusion === "success") { if (!agentOutputResult.success || !agentOutputResult.items || agentOutputResult.items.length === 0) { @@ -4204,7 +4206,8 @@ async function main() { // safe outputs such as add_comment, preventing a tool-failure narrative from being // classified as a successful review or other completed task. if (agentOutputResult.success && agentOutputResult.items && agentOutputResult.items.length > 0) { - const reportIncompleteItems = agentOutputResult.items.filter(item => item.type === "report_incomplete"); + // Cancellation can interrupt the agent before it emits a terminal safe output. + const reportIncompleteItems = agentOutputResult.items.filter(item => item.type === "report_incomplete" && !(isCancelledMissingTerminalOutput && item.reason === collectorEmptyOutputCause)); if (reportIncompleteItems.length > 0) { if (taskCompleteRegistrationIssueOnlyReportIncomplete) { core.info("Ignoring report_incomplete signal(s) caused only by task_complete registration trouble after successful task-level outputs"); @@ -4412,12 +4415,13 @@ async function main() { const modelEndpointMismatchContext = buildModelEndpointMismatchContext(modelEndpointMismatch); // A model/endpoint mismatch explains the missing terminal output, so the collector's // generic empty-output classification (e.g. engine_outage) must not override it. - const collectorEmptyOutputCause = agentOutputResult.success ? agentOutputResult.collectorEmptyOutputCause : undefined; const hasModelConfigurationError = hasModelEndpointMismatch || modelNotSupportedError; - const emptyOutputCause = hasModelConfigurationError ? undefined : collectorEmptyOutputCause; - const terminalOutputFailureCause = hasModelConfigurationError || !agentOutputResult.success ? undefined : agentOutputResult.collectorFailureCause; + const emptyOutputCause = hasModelConfigurationError || isCancelledMissingTerminalOutput ? undefined : collectorEmptyOutputCause; + const terminalOutputFailureCause = hasModelConfigurationError || !agentOutputResult.success || isCancelledMissingTerminalOutput ? undefined : agentOutputResult.collectorFailureCause; const reportItems = (Array.isArray(agentOutputResult.items) ? agentOutputResult.items : []).filter( - item => !(hasModelConfigurationError && collectorEmptyOutputCause && item?.type === "report_incomplete" && item.reason === collectorEmptyOutputCause && typeof item.failureCause === "string") + item => + !(hasModelConfigurationError && collectorEmptyOutputCause && item?.type === "report_incomplete" && item.reason === collectorEmptyOutputCause && typeof item.failureCause === "string") && + !(isCancelledMissingTerminalOutput && item?.type === "report_incomplete" && item.reason === collectorEmptyOutputCause) ); const hasReportedIncomplete = hasReportIncomplete && reportItems.some(item => item?.type === "report_incomplete"); const issueTitle = buildFailureIssueTitle({ diff --git a/setup/js/harness_error_patterns.cjs b/setup/js/harness_error_patterns.cjs index 6c34e9e9..34e48a11 100644 --- a/setup/js/harness_error_patterns.cjs +++ b/setup/js/harness_error_patterns.cjs @@ -90,6 +90,65 @@ const API_PROXY_GUARD_REJECTION_PATTERNS = [ { guard: "model_policy_violation", pattern: /\bmodel_policy_violation\b/i, counterRe: null, counterNames: [] }, ]; +/** + * Select diagnostic fields without inspecting metadata, tool input, or assistant prose. + * The caller must first attribute the record as a native error. + * @param {any} record + * @returns {any} + */ +function selectNativeErrorDiagnostic(record) { + /** @param {any} value @param {number} depth @returns {any} */ + const select = (value, depth) => { + if (depth > 8) return undefined; + if (typeof value === "string") { + try { + return select(JSON.parse(value), depth + 1); + } catch { + return value; + } + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const diagnostic = {}; + for (const key of ["type", "code", "error_code", "errorCode", "error_type", "errorType", "terminal_reason", "statusCode", "status_code", "status", "error_status", "api_error_status", "subtype"]) { + if (typeof value[key] === "string" || typeof value[key] === "number") diagnostic[key] = value[key]; + } + for (const key of ["error", "message", "errorMessage"]) { + if (value[key] !== undefined) diagnostic[key] = select(value[key], depth + 1); + } + if (value.is_api_error_message === true) diagnostic.is_api_error_message = true; + // Claude marks API-error assistant content as transport text, not conversation. + // Its canonical error envelope retains the error identifier and assistant message. + if ((value.type === "assistant" && value.is_api_error_message === true) || (typeof value.error === "string" && value.message?.role === "assistant")) { + const content = value.message?.content; + if (Array.isArray(content)) + diagnostic.message = content + .filter(block => block?.type === "text" && typeof block.text === "string") + .map(block => { + const text = select(block.text, depth + 1); + return typeof text === "string" ? text : (JSON.stringify(text) ?? ""); + }) + .join("\n"); + } + if (value.is_error === true) { + diagnostic.is_error = true; + diagnostic.result = select(value.result, depth + 1); + if (Array.isArray(value.errors)) diagnostic.errors = value.errors.map(error => select(error, depth + 1)); + } + return diagnostic; + }; + return select(record, 0); +} + +/** + * Endpoint incompatibility wins only within the originating diagnostic. + * @param {string} guard + * @param {string} diagnostic + * @returns {boolean} + */ +function shouldSuppressGuardCategory(guard, diagnostic) { + return guard === "model_policy_violation" && parseModelEndpointMismatch(diagnostic) !== null; +} + /** * Detect an AWF API proxy guardrail rejection in harness output. * @@ -104,6 +163,7 @@ const API_PROXY_GUARD_REJECTION_PATTERNS = [ function parseAPIProxyGuardRejection(output) { const safeOutput = typeof output === "string" ? output : ""; if (!safeOutput) return null; + const diagnostics = []; for (const line of safeOutput.split(/\r?\n/)) { let record; try { @@ -112,12 +172,16 @@ function parseAPIProxyGuardRejection(output) { continue; } if (!record || typeof record !== "object" || Array.isArray(record)) continue; - const candidates = - record.type === "unknown_model_ai_credits" - ? [record] - : record.type === "error" || record.type === "turn.failed" || record.is_api_error_message === true || (record.type === undefined && record.error) - ? [record.error, record.message] - : []; + const isApiErrorEnvelope = record.type === "assistant" && record.is_api_error_message === true; + const isNativeTransportError = record.type === "error" || record.type === "turn.failed" || record.type === "session.error"; + const isGuardRecord = API_PROXY_GUARD_REJECTION_PATTERNS.some(({ guard }) => record.type === guard || ((isNativeTransportError || isApiErrorEnvelope) && record.code === guard)); + const isFailedResult = record.type === "result" && record.is_error === true; + const isTransportError = isNativeTransportError || isApiErrorEnvelope || isFailedResult; + if (isGuardRecord || isTransportError) { + const diagnostic = record.type === "session.error" && record.data && typeof record.data === "object" ? record.data : record; + diagnostics.push(JSON.stringify(selectNativeErrorDiagnostic(diagnostic)) ?? ""); + } + const candidates = record.type === "unknown_model_ai_credits" ? [record] : isNativeTransportError || isApiErrorEnvelope ? [record.error, record.message] : []; // Only unwrap transport errors, never assistant messages or tool results. for (let visited = 0; visited < 8 && candidates.length > 0; visited++) { let candidate = candidates.shift(); @@ -135,20 +199,22 @@ function parseAPIProxyGuardRejection(output) { candidates.push(candidate.error, candidate.message); } } + // Tool results can quote entire lock files containing guard names. Only inspect + // transport diagnostics, not assistant content or command output. for (const { guard, pattern, counterRe, counterNames } of API_PROXY_GUARD_REJECTION_PATTERNS) { - if (!pattern.test(safeOutput)) continue; - // Endpoint incompatibility has its own diagnostic, even if wrapped as a policy violation. - if (guard === "model_policy_violation" && parseModelEndpointMismatch(safeOutput)) continue; - /** @type {Record} */ - const counters = {}; - const match = counterRe ? counterRe.exec(safeOutput) : null; - if (match) { - counterNames.forEach((name, index) => { - const value = Number.parseInt(match[index + 1], 10); - if (Number.isFinite(value)) counters[name] = value; - }); + for (const diagnosticText of diagnostics) { + if (!pattern.test(diagnosticText) || shouldSuppressGuardCategory(guard, diagnosticText)) continue; + /** @type {Record} */ + const counters = {}; + const match = counterRe ? counterRe.exec(diagnosticText) : null; + if (match) { + counterNames.forEach((name, index) => { + const value = Number.parseInt(match[index + 1], 10); + if (Number.isFinite(value)) counters[name] = value; + }); + } + return { guard, counters }; } - return { guard, counters }; } return null; } @@ -217,11 +283,20 @@ function parseAICreditsExceededProxyRejection(output) { // prettier-ignore const record = /** @type {Record} */ (parsed); const nativeError = record.error; - const isCodexApiError = - (record.type === "error" && typeof record.message === "string") || (record.type === "turn.failed" && nativeError && typeof nativeError === "object" && "message" in nativeError && typeof nativeError.message === "string"); - const isEngineFlaggedApiError = record.is_api_error_message === true || typeof record.error === "string" || isCodexApiError; + const nativeMessage = nativeError && typeof nativeError === "object" && "message" in nativeError ? nativeError.message : undefined; + const isCodexApiError = (record.type === "error" && typeof record.message === "string") || (record.type === "turn.failed" && typeof nativeMessage === "string"); + const isClaudeFailedResult = record.type === "result" && record.is_error === true; + const isClaudeApiError = record.type === "assistant" && record.is_api_error_message === true; + const isEngineFlaggedApiError = isClaudeApiError || isCodexApiError || isClaudeFailedResult; if (!isEngineFlaggedApiError) continue; - const match = AI_CREDITS_EXCEEDED_PROXY_REJECTION_RE.exec(JSON.stringify(record)); + const diagnostic = isCodexApiError + ? record.type === "error" + ? record.message + : nativeMessage + : isClaudeFailedResult + ? JSON.stringify({ result: record.result, errors: record.errors }) + : JSON.stringify({ error: record.error, message: record.message }); + const match = AI_CREDITS_EXCEEDED_PROXY_REJECTION_RE.exec(typeof diagnostic === "string" ? diagnostic : ""); if (!match) continue; const aiCredits = Number.parseFloat(match[1]); const maxAICredits = Number.parseFloat(match[2]); @@ -263,5 +338,7 @@ module.exports = { MODEL_ENDPOINT_MISMATCH_PATTERN, parseModelEndpointMismatch, parseAPIProxyGuardRejection, + selectNativeErrorDiagnostic, + shouldSuppressGuardCategory, parseAICreditsExceededProxyRejection, }; diff --git a/setup/js/json_string_scanner.cjs b/setup/js/json_string_scanner.cjs new file mode 100644 index 00000000..9421f5a8 --- /dev/null +++ b/setup/js/json_string_scanner.cjs @@ -0,0 +1,61 @@ +// @ts-check + +/** + * Visit quoted JSON tokens without a repeated regex alternation, which can + * exhaust the RegExp stack on multi-megabyte log records. + * Malformed prefixes are preserved as text; their quote pairing is ambiguous. + * @param {string} text + * @param {(token: string) => string} transformString + * @param {(text: string) => string} [transformText] + * @returns {string} + */ +function transformJSONStrings(text, transformString, transformText = value => value) { + const parts = []; + let cursor = 0; + while (cursor < text.length) { + const start = text.indexOf('"', cursor); + if (start === -1) { + parts.push(transformText(text.slice(cursor))); + break; + } + let end = start + 1; + let invalidString = false; + while (end < text.length && text[end] !== '"') { + if (text.charCodeAt(end) < 0x20) { + invalidString = true; + break; + } + if (text[end] === "\\") { + end++; + const escaped = text[end]; + if (escaped === "u") { + if (!/^[0-9a-fA-F]{4}$/.test(text.slice(end + 1, end + 5))) { + invalidString = true; + break; + } + end += 4; + } else if (!escaped || !'"\\/bfnrt'.includes(escaped)) { + invalidString = true; + break; + } + } + end++; + } + if (invalidString) { + // Do not rescan escaped quotes in the rejected prefix. + parts.push(transformText(text.slice(cursor, end))); + cursor = end; + continue; + } + if (end >= text.length) { + parts.push(transformText(text.slice(cursor))); + break; + } + parts.push(transformText(text.slice(cursor, start))); + parts.push(transformString(text.slice(start, end + 1))); + cursor = end + 1; + } + return parts.join(""); +} + +module.exports = { transformJSONStrings }; diff --git a/setup/js/mask_value_matcher.cjs b/setup/js/mask_value_matcher.cjs new file mode 100644 index 00000000..07bc6942 --- /dev/null +++ b/setup/js/mask_value_matcher.cjs @@ -0,0 +1,132 @@ +// @ts-check + +/** Replacement used for masked values, matching the runner's own rendering. */ +const MASK_REPLACEMENT = "***"; + +/** + * Yield non-overlapping matches, breaking equal-position ties by mask order. + * @param {string} text + * @param {string[]} maskedValues + * @param {(match: {start: number, end: number, value: string}) => boolean} [allowed] + * @param {boolean} [overlapping] + * @yields {{start: number, end: number, value: string}} + */ +function* maskedValueMatches(text, maskedValues, allowed = () => true, overlapping = false) { + const pending = []; + for (let order = 0; order < maskedValues.length; order++) { + const value = maskedValues[order]; + if (!value) continue; + const index = text.indexOf(value); + if (index !== -1) pending.push({ value, index, order }); + } + const precedes = (a, b) => a.index < b.index || (a.index === b.index && a.order < b.order); + const siftDown = start => { + let parent = start; + while (parent * 2 + 1 < pending.length) { + let child = parent * 2 + 1; + if (child + 1 < pending.length && precedes(pending[child + 1], pending[child])) child++; + if (!precedes(pending[child], pending[parent])) break; + [pending[parent], pending[child]] = [pending[child], pending[parent]]; + parent = child; + } + }; + for (let i = Math.floor(pending.length / 2) - 1; i >= 0; i--) siftDown(i); + let cursor = 0; + while (pending.length && cursor < text.length) { + const next = pending[0]; + let searchStart = cursor; + if (next.index >= cursor) { + const match = { start: next.index, end: next.index + next.value.length, value: next.value }; + if (allowed(match)) { + yield match; + cursor = overlapping ? match.start + 1 : match.end; + searchStart = cursor; + } else { + searchStart = next.index + 1; + } + } + // Only refresh consumed or overlapping occurrences; later candidates stay cached. + next.index = text.indexOf(next.value, searchStart); + if (next.index === -1) { + const last = pending.pop(); + if (pending.length) pending[0] = last; + } + siftDown(0); + } +} + +/** + * @param {Iterable<{start: number, end: number, value: string, ambiguous?: boolean}>} matches + */ +function* overlappingMatchGroups(matches) { + let group; + for (const match of matches) { + if (group && match.start < group.end) { + group.end = Math.max(group.end, match.end); + if (match.value.length > group.value.length) group.value = match.value; + group.ambiguous ||= match.ambiguous; + } else { + if (group) yield group; + group = { ...match }; + } + } + if (group) yield group; +} + +/** + * Coalesce overlapping credentials before choosing their replacement marker. + * @param {string} text + * @param {string[]} maskedValues - Longest credentials first + * @param {(value: string) => string} replacementForValue + * @returns {string} + */ +function redactOverlappingMaskedValues(text, maskedValues, replacementForValue) { + const parts = []; + let cursor = 0; + for (const match of overlappingMatchGroups(maskedValueMatches(text, maskedValues, undefined, true))) { + parts.push(text.slice(cursor, match.start), replacementForValue(match.value)); + cursor = match.end; + } + parts.push(text.slice(cursor)); + return parts.join(""); +} + +/** + * Replace all occurrences of the masked values, defaulting to `***`. + * + * @param {string} text + * @param {string[]} maskedValues + * @param {(value: string) => string} [replacementForValue] - Explicit markers coalesce overlapping credentials + * @returns {string} + */ +function redactMaskedValues(text, maskedValues, replacementForValue) { + if (!text || !maskedValues || maskedValues.length === 0) return text; + if (replacementForValue) + return redactOverlappingMaskedValues( + text, + [...maskedValues].sort((a, b) => b.length - a.length), + replacementForValue + ); + const parts = []; + let cursor = 0; + let replacements = 0; + for (const match of maskedValueMatches(text, maskedValues)) { + if (match.start > cursor) { + if (replacements) parts.push(MASK_REPLACEMENT.repeat(replacements)); + replacements = 0; + parts.push(text.slice(cursor, match.start)); + } + replacements++; + cursor = match.end; + } + if (replacements) parts.push(MASK_REPLACEMENT.repeat(replacements)); + parts.push(text.slice(cursor)); + return parts.join(""); +} + +module.exports = { + MASK_REPLACEMENT, + maskedValueMatches, + overlappingMatchGroups, + redactMaskedValues, +}; diff --git a/setup/js/package.json b/setup/js/package.json index f8ed00b3..93c46322 100644 --- a/setup/js/package.json +++ b/setup/js/package.json @@ -21,6 +21,7 @@ }, "overrides": { "magic-string": "1.4.2", + "minimatch@3": "^3.1.5", "undici": "^6.23.0", "vite": { "postcss": { diff --git a/setup/js/parse_mcp_gateway_log.cjs b/setup/js/parse_mcp_gateway_log.cjs index e6218566..eb1f111d 100644 --- a/setup/js/parse_mcp_gateway_log.cjs +++ b/setup/js/parse_mcp_gateway_log.cjs @@ -9,6 +9,8 @@ const { formatModelEmojiAlias } = require("./model_aliases.cjs"); const { computeInferenceAIC, formatAIC } = require("./model_costs.cjs"); const { parseUnknownModelAICreditsFromAuditLog } = require("./ai_credits_context.cjs"); const { getMcpRateLimitHint, getRpcRateLimitHint } = require("./mcp_rate_limit.cjs"); +const { MAX_STEP_SUMMARY_SIZE } = require("./log_parser_shared.cjs"); +const { boundSummaryLines } = require("./agent_session_render.cjs"); /** * Parses MCP gateway logs and creates a step summary @@ -380,13 +382,54 @@ function generateTokenUsageSummary(summary) { } /** - * Writes the step summary and exports AI credit metadata when token usage data exists. + * Bound oversized sections without leaving partial Markdown/HTML structures open. + * The complete redacted logs remain in the existing agent artifact. + * @param {string} content + * @param {number} maxBytes + * @returns {string} + */ +function boundGatewaySummary(content, maxBytes) { + if (Buffer.byteLength(content, "utf8") <= maxBytes) return content; + const notice = `MCP gateway summary truncated (${Buffer.byteLength(content, "utf8")} bytes). Full redacted logs: \`mcp-logs/\` in the agent artifact.\n\n`; + const header = `${notice}
\nMCP gateway diagnostic preview\n
\n`;
+  const footer = "\n
\n
\n"; + const budget = maxBytes - Buffer.byteLength(header + footer, "utf8"); + if (budget < 128) return Buffer.byteLength(notice, "utf8") <= maxBytes ? notice : ""; + const lines = content.split("\n").map(line => escapeHtml(line)); + return header + boundSummaryLines(lines, budget, 4096).join("\n") + footer; +} + +/** + * @param {typeof import('@actions/core')} coreObj + * @param {string[]} sections + */ +function appendGatewaySummary(coreObj, sections) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + let remaining = MAX_STEP_SUMMARY_SIZE; + try { + if (summaryPath && fs.existsSync(summaryPath)) remaining -= fs.statSync(summaryPath).size; + } catch (error) { + throw new Error(`${ERR_SYSTEM}: Failed to measure existing MCP gateway step summary: ${getErrorMessage(error)}`, { cause: error }); + } + for (const [index, section] of sections.entries()) { + // Share the remaining capacity so a verbose gateway log cannot crowd out + // later rate-limit, steering, or DIFC diagnostics. + const budget = Math.max(0, Math.floor(remaining / (sections.length - index))); + const bounded = boundGatewaySummary(section, budget); + if (bounded !== section) coreObj.warning("MCP gateway step summary byte limit reached; full redacted logs are in the agent artifact."); + if (bounded) coreObj.summary.addRaw(bounded); + remaining -= Buffer.byteLength(bounded, "utf8"); + } +} + +/** + * Writes bounded gateway diagnostics and exports AI credit metadata. * Token Usage rendering is handled by parse_token_usage.cjs to avoid duplicate sections. - * This is the final call in each main() exit path — it consolidates the summary write - * so callers don't need to chain addRaw() + write() themselves. * @param {typeof import('@actions/core')} coreObj - The GitHub Actions core object + * @param {string[]} [sections] - Gateway sections sharing this step's byte budget */ -async function writeStepSummaryWithTokenUsage(coreObj) { +async function writeStepSummaryWithTokenUsage(coreObj, sections = []) { + appendGatewaySummary(coreObj, sections); if (!fs.existsSync(TOKEN_USAGE_PATH)) { coreObj.debug(`No token-usage.jsonl found at: ${TOKEN_USAGE_PATH}`); } else { @@ -1043,6 +1086,7 @@ function generateRpcMessagesSummary(entries, difcFilteredEvents) { */ async function main() { try { + const summarySections = []; // First, print all gateway-related files for debugging printAllGatewayFiles(); @@ -1113,7 +1157,7 @@ async function main() { const rateLimitSummary = generateMcpRateLimitSummary(`${gatewayJsonlContent}\n${rpcMessagesContent || ""}`); if (rateLimitSummary) { core.warning("MCP tools returned HTTP 429; see the MCP rate limits summary for retry hints."); - core.summary.addRaw(rateLimitSummary); + summarySections.push(rateLimitSummary); } // Always scan authoritative text logs for AI credits signals before selecting @@ -1152,27 +1196,27 @@ async function main() { unknownModelAICredits ||= hasUnknownModelAICreditsError([gatewayMdContent]); // Write the markdown directly to the step summary - core.summary.addRaw(gatewayMdContent.endsWith("\n") ? gatewayMdContent : gatewayMdContent + "\n"); + summarySections.push(gatewayMdContent.endsWith("\n") ? gatewayMdContent : gatewayMdContent + "\n"); // Append any proxy-side steering or DIFC_FILTERED sections after the gateway summary if (tokenSteeringEvents.length > 0) { const steeringSummary = generateTokenSteeringSummary(tokenSteeringEvents); - core.summary.addRaw(steeringSummary); + summarySections.push(steeringSummary); } if (modelAliasResolutionEvents.length > 0) { const modelAliasResolutionSummary = generateModelAliasResolutionSummary(modelAliasResolutionEvents); - core.summary.addRaw(modelAliasResolutionSummary); + summarySections.push(modelAliasResolutionSummary); } if (difcFilteredEvents.length > 0) { const difcSummary = generateDifcFilteredSummary(difcFilteredEvents); - core.summary.addRaw(difcSummary); + summarySections.push(difcSummary); } setAICreditsRateLimitOutput(core, aiCreditsRateLimitError); setUnknownModelAICreditsOutput(core, unknownModelAICredits); - await writeStepSummaryWithTokenUsage(core); + await writeStepSummaryWithTokenUsage(core, summarySections); return; } } else { @@ -1190,20 +1234,20 @@ async function main() { if (totalMessages > 0 || difcFilteredEvents.length > 0) { const rpcSummary = generateRpcMessagesSummary(rpcEntries, difcFilteredEvents); if (rpcSummary.length > 0) { - core.summary.addRaw(rpcSummary); + summarySections.push(rpcSummary); } if (tokenSteeringEvents.length > 0) { - core.summary.addRaw(generateTokenSteeringSummary(tokenSteeringEvents)); + summarySections.push(generateTokenSteeringSummary(tokenSteeringEvents)); } if (modelAliasResolutionEvents.length > 0) { - core.summary.addRaw(generateModelAliasResolutionSummary(modelAliasResolutionEvents)); + summarySections.push(generateModelAliasResolutionSummary(modelAliasResolutionEvents)); } } else { core.info("rpc-messages.jsonl is present but contains no renderable messages"); } setAICreditsRateLimitOutput(core, aiCreditsRateLimitError); setUnknownModelAICreditsOutput(core, unknownModelAICredits); - await writeStepSummaryWithTokenUsage(core); + await writeStepSummaryWithTokenUsage(core, summarySections); return; } @@ -1219,7 +1263,7 @@ async function main() { core.info("MCP gateway log files are empty or missing"); setAICreditsRateLimitOutput(core, aiCreditsRateLimitError); setUnknownModelAICreditsOutput(core, unknownModelAICredits); - await writeStepSummaryWithTokenUsage(core); + await writeStepSummaryWithTokenUsage(core, summarySections); return; } @@ -1229,19 +1273,15 @@ async function main() { core.info(plainTextSummary); } - // Generate step summary: legacy logs + DIFC filtered section + // Keep supplemental diagnostics separate from potentially verbose legacy logs. const legacySummary = generateGatewayLogSummary(gatewayLogContent, stderrLogContent); const steeringSummary = generateTokenSteeringSummary(tokenSteeringEvents); const modelAliasResolutionSummary = generateModelAliasResolutionSummary(modelAliasResolutionEvents); const difcSummary = generateDifcFilteredSummary(difcFilteredEvents); - const fullSummary = [legacySummary, steeringSummary, modelAliasResolutionSummary, difcSummary].filter(s => s.length > 0).join("\n"); - - if (fullSummary.length > 0) { - core.summary.addRaw(fullSummary); - } + summarySections.push(...[legacySummary, steeringSummary, modelAliasResolutionSummary, difcSummary].filter(s => s.length > 0)); setAICreditsRateLimitOutput(core, aiCreditsRateLimitError); setUnknownModelAICreditsOutput(core, unknownModelAICredits); - await writeStepSummaryWithTokenUsage(core); + await writeStepSummaryWithTokenUsage(core, summarySections); } catch (error) { core.setFailed(`${ERR_PARSE}: ${getErrorMessage(error)}`); } @@ -1375,6 +1415,7 @@ if (typeof module !== "undefined" && module.exports) { formatDurationMs, formatAICForOutput, writeStepSummaryWithTokenUsage, + boundGatewaySummary, hasAICreditsRateLimitError, hasUnknownModelAICreditsError, setUnknownModelAICreditsOutput, diff --git a/setup/js/redact_secrets.cjs b/setup/js/redact_secrets.cjs index f1d6e4c7..f7f21e6d 100644 --- a/setup/js/redact_secrets.cjs +++ b/setup/js/redact_secrets.cjs @@ -10,7 +10,9 @@ const fs = require("fs"); const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); -const { collectAddMaskedValues, redactArtifactMaskedValues } = require("./add_mask_redaction.cjs"); +const { applyAddMaskRedaction, collectAddMaskedValues, isAddMaskCommandLine, redactArtifactMaskedValues } = require("./add_mask_redaction.cjs"); +const { maskedValueMatches, overlappingMatchGroups } = require("./mask_value_matcher.cjs"); +const { redactSafeOutputContent } = require("./safe_output_redaction.cjs"); const { redactPiSessionHTML } = require("./pi_session_redaction.cjs"); const { getOTLPSecretValues } = require("./otlp_header_helpers.cjs"); const { redactSensitiveConfig } = require("./safe_outputs_config_redact.cjs"); @@ -303,6 +305,99 @@ function redactSecrets(content, secretValues) { return { content: redacted, redactionCount }; } +/** + * @param {string} content + * @returns {boolean} + */ +function isValidJSONArtifact(content) { + try { + JSON.parse(content); + return true; + } catch { + const records = content.split(/\r?\n/).filter(line => line.trim()); + return ( + records.length > 0 && + records.every(record => { + try { + JSON.parse(record); + return true; + } catch { + return false; + } + }) + ); + } +} + +/** + * Match all credential sources before any replacement or JSON serialization can + * fragment a longer secret or change its spelling. + * @param {string} content + * @param {string[]} secretValues + * @param {string[]} maskedValues + * @param {{decodedText?: boolean}} [options] - Treat already-decoded protocol payloads as literal text + * @returns {{content: string, redactionCount: number}} + */ +function redactArtifactContent(content, secretValues, maskedValues, options = {}) { + const configured = new Set(secretValues.filter(value => value && value.length >= 6)); + /** @type {Map} */ + const builtInValues = new Map(); + // Discovery only: include escaped built-in credentials without rewriting the + // original numeric lexemes, malformed records, or unchanged JSON strings. + const escapes = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + const decoded = options.decodedText ? content : content.replace(/\\(["\\/bfnrt]|u[0-9a-fA-F]{4})/g, (_, escape) => (escape[0] === "u" ? String.fromCharCode(parseInt(escape.slice(1), 16)) : escapes[escape])); + for (const text of new Set([content, decoded])) { + for (const { name, pattern } of BUILT_IN_PATTERNS) { + for (const match of text.matchAll(pattern)) builtInValues.set(match[0], name); + } + } + const values = [...new Set([...configured, ...builtInValues.keys(), ...maskedValues])].sort((a, b) => b.length - a.length); + let redactionCount = 0; + const builtInCounts = new Map(); + let configuredCount = 0; + const replacement = value => { + redactionCount++; + const name = builtInValues.get(value); + if (name) builtInCounts.set(name, (builtInCounts.get(name) || 0) + 1); + else if (configured.has(value)) configuredCount++; + return name || configured.has(value) ? "***REDACTED***" : "***"; + }; + // Protocol callbacks already receive decoded strings, not serialized artifacts. + const redacted = options.decodedText ? redactDecodedCredentialText(content, values, replacement) : redactArtifactMaskedValues(content, values, undefined, replacement, { textMode: !isValidJSONArtifact(content) }); + for (const [name, count] of builtInCounts) global.core?.info?.(`Redacted ${count} occurrence(s) of ${name}`); + if (configuredCount) global.core?.info?.(`Redacted ${configuredCount} occurrence(s) of a secret`); + return { content: redacted, redactionCount: redacted === content ? 0 : Math.max(1, redactionCount) }; +} + +/** + * Keep command lines intact until removal, unless a credential union crosses + * their boundary and must be replaced before removing any original text. + * @param {string} content + * @param {string[]} values + * @param {(value: string) => string} replacement + * @returns {string} + */ +function redactDecodedCredentialText(content, values, replacement) { + const commands = []; + let offset = 0; + for (const line of content.split("\n")) { + if (isAddMaskCommandLine(line)) commands.push({ start: offset, end: offset + line.length }); + offset += line.length + 1; + } + const parts = []; + let cursor = 0; + let commandIndex = 0; + for (const match of overlappingMatchGroups(maskedValueMatches(content, values, undefined, true))) { + while (commandIndex < commands.length && commands[commandIndex].end < match.start) commandIndex++; + const command = commands[commandIndex]; + if (command && match.start >= command.start && match.end <= command.end) continue; + parts.push(content.slice(cursor, match.start), replacement(match.value)); + cursor = match.end; + } + parts.push(content.slice(cursor)); + return applyAddMaskRedaction(parts.join(""), []); +} + /** * Validate and read the host-only AWF early configuration artifact. The exact * path contract and metadata checks prevent an environment variable from @@ -498,26 +593,29 @@ function processFile(filePath, secretValues, maskedValues = [], prepareForCustom let stage = "read"; try { const content = fs.readFileSync(filePath, "utf8"); + const inputBytes = Buffer.byteLength(content, "utf8"); + core.info(`Secret redaction input: bytes=${inputBytes}`); stage = "redact"; - const encodedResult = - path.extname(filePath).toLowerCase() === ".html" - ? redactPiSessionHTML(content, text => { - const masked = redactArtifactMaskedValues(text, maskedValues); - const builtIn = redactBuiltInPatterns(masked); - const custom = redactSecrets(builtIn.content, secretValues); - return { content: custom.content, redactionCount: builtIn.redactionCount + custom.redactionCount + (masked !== text ? 1 : 0) }; - }) - : { content, redactionCount: 0 }; - const runtimeRedacted = redactArtifactMaskedValues(encodedResult.content, maskedValues); - // First, redact built-in patterns - const builtInResult = redactBuiltInPatterns(runtimeRedacted); - let redacted = builtInResult.content; - let totalRedactions = encodedResult.redactionCount + builtInResult.redactionCount + (runtimeRedacted !== encodedResult.content ? 1 : 0); - - // Then, redact custom secrets - const customResult = redactSecrets(redacted, secretValues); - redacted = customResult.content; - totalRedactions += customResult.redactionCount; + const structured = redactSafeOutputContent( + filePath, + content, + [...maskedValues, ...secretValues.filter(value => value && value.length >= 6)].sort((a, b) => b.length - a.length), + text => redactArtifactContent(text, secretValues, maskedValues, { decodedText: true }).content + ); + if (structured !== null) { + const redactionCount = structured === content ? 0 : 1; + if (redactionCount || prepareForCustomMasking) { + stage = "write"; + writeProcessedFile(filePath, structured, Boolean(redactionCount)); + } + if (redactionCount) core.info(`Processed ${filePath}: ${redactionCount} redaction(s)`); + core.info(`Secret redaction sizes: input_bytes=${inputBytes} output_bytes=${Buffer.byteLength(structured, "utf8")} format=structured`); + return redactionCount; + } + const encodedResult = path.extname(filePath).toLowerCase() === ".html" ? redactPiSessionHTML(content, text => redactArtifactContent(text, secretValues, maskedValues)) : { content, redactionCount: 0 }; + const genericResult = redactArtifactContent(encodedResult.content, secretValues, maskedValues); + const redacted = genericResult.content; + const totalRedactions = encodedResult.redactionCount + genericResult.redactionCount; if (totalRedactions > 0 || prepareForCustomMasking) { stage = "write"; @@ -526,6 +624,7 @@ function processFile(filePath, secretValues, maskedValues = [], prepareForCustom if (totalRedactions > 0) { core.info(`Processed ${filePath}: ${totalRedactions} redaction(s)`); } + core.info(`Secret redaction sizes: input_bytes=${inputBytes} output_bytes=${Buffer.byteLength(redacted, "utf8")} format=text`); return totalRedactions; } catch (error) { if (isErrnoCode(error, "ENOENT") && !fs.existsSync(filePath)) { diff --git a/setup/js/report_failed_jobs.cjs b/setup/js/report_failed_jobs.cjs index f86750d4..fa976ead 100644 --- a/setup/js/report_failed_jobs.cjs +++ b/setup/js/report_failed_jobs.cjs @@ -130,6 +130,8 @@ async function getFailedNonBuiltinJobs() { /** @type {Array<{name: string, html_url: string | null, id?: number, error_detail?: string, failed_steps?: number[]}>} */ const runJobs = []; + /** @type {import("./workflow_job_durations.cjs").JobTiming[]} */ + const timingJobs = []; let page = 1; const perPage = 100; @@ -145,6 +147,7 @@ async function getFailedNonBuiltinJobs() { }); const jobs = response.data.jobs; + timingJobs.push(...jobs); core.info(`Page ${page}: retrieved ${jobs.length} jobs`); for (const job of jobs) { @@ -165,6 +168,10 @@ async function getFailedNonBuiltinJobs() { page++; } + const { collectJobDurations } = require("./workflow_job_durations.cjs"); + const durations = await collectJobDurations({ jobs: timingJobs, warn: message => core.warning(message) }); + core.setOutput("job_durations", JSON.stringify(durations)); + const matchesName = (name, display) => name === display || name.startsWith(`${display} (`) || name.startsWith(`${display} / `); const knownIds = new Set([...Object.keys(jobResults), ...Object.keys(jobDisplayNames)]); const failedJobs = []; diff --git a/setup/js/restore_aic_scan_cache.cjs b/setup/js/restore_aic_scan_cache.cjs index d542939f..21cdbac9 100644 --- a/setup/js/restore_aic_scan_cache.cjs +++ b/setup/js/restore_aic_scan_cache.cjs @@ -19,21 +19,25 @@ function isTrustedProducer(run, current, repository, defaultBranch) { ); } -async function listRequiredWorkflowRuns(owner, repo, current, repository, defaultBranch, budget) { - const runs = []; +async function* listRequiredWorkflowRuns(owner, repo, current, repository, defaultBranch, budget) { + let candidates = 0; const created = `>=${new Date(Date.now() - SCAN_WINDOW_MS).toISOString()}`; - for (let page = 1; page <= MAX_RESTORE_PAGES && runs.length < MAX_RESTORE_RUNS; page++) { + for (let page = 1; page <= MAX_RESTORE_PAGES && candidates < MAX_RESTORE_RUNS; page++) { const response = await budget.request(() => github.rest.actions.listWorkflowRunsForRepo({ owner, repo, status: "completed", created, per_page: 100, page }), true, API_READ_OPERATIONS.REPOSITORY_HISTORY); const pageRuns = response.data.workflow_runs || []; const trustedRuns = pageRuns.filter(run => run.id !== context.runId && isTrustedProducer(run, current, repository, defaultBranch)); core.info(`[daily-aic-cache] Evaluated repository history page: ${JSON.stringify({ page, listedRuns: pageRuns.length, trustedCandidates: trustedRuns.length })}`); - runs.push(...trustedRuns.slice(0, MAX_RESTORE_RUNS - runs.length)); + // Inspect each page before spending quota on older history. A verified + // snapshot ends restoration; the guardrail still lists its complete window. + for (const run of trustedRuns.slice(0, MAX_RESTORE_RUNS - candidates)) { + candidates++; + yield run; + } if (pageRuns.length < 100) break; } - return runs; } -async function listRecentWorkflowRuns(owner, repo, current, repository, defaultBranch, budget) { +async function* listRecentWorkflowRuns(owner, repo, current, repository, defaultBranch, budget) { let response; core.info(`[daily-aic-cache] Querying workflow-specific run history: ${JSON.stringify({ workflowId: current.workflow_id })}`); try { @@ -41,10 +45,11 @@ async function listRecentWorkflowRuns(owner, repo, current, repository, defaultB } catch (error) { if (error?.status !== 404 || isQuotaError(error) || safeResponseHeaders(error?.response?.headers)["retry-after"] != null) throw error; core.info("[daily-aic-cache] Workflow-specific history unavailable; searching repository history for required workflow runs."); - return listRequiredWorkflowRuns(owner, repo, current, repository, defaultBranch, budget); + yield* listRequiredWorkflowRuns(owner, repo, current, repository, defaultBranch, budget); + return; } core.info(`[daily-aic-cache] Workflow-specific run history returned candidates: ${JSON.stringify({ workflowId: current.workflow_id, runs: response.data.workflow_runs?.length ?? 0 })}`); - return response.data.workflow_runs; + yield* response.data.workflow_runs; } async function restoreWithBudget(cachePath, options, budget) { @@ -55,13 +60,9 @@ async function restoreWithBudget(cachePath, options, budget) { const current = currentResponse.data; if (!Number.isSafeInteger(current?.workflow_id) || current.workflow_id <= 0) throw new Error("Cannot resolve workflow for daily AIC snapshot restore"); const defaultBranch = context.payload.repository?.default_branch; - const runs = await listRecentWorkflowRuns(owner, repo, current, repository, defaultBranch, budget); - const auth = await github.auth({ type: "token" }); - if (!auth || typeof auth !== "object" || !("token" in auth) || typeof auth.token !== "string" || !auth.token) { - throw new Error("No token available to restore daily AIC observations"); - } - const token = auth.token; - for (const run of runs) { + const runs = listRecentWorkflowRuns(owner, repo, current, repository, defaultBranch, budget); + let token; + for await (const run of runs) { const isCurrentRun = run.id === context.runId; const trusted = isTrustedProducer(run, current, repository, defaultBranch); core.info( @@ -77,6 +78,13 @@ async function restoreWithBudget(cachePath, options, budget) { })}` ); if (isCurrentRun || !trusted) continue; + if (!token) { + const auth = await github.auth({ type: "token" }); + if (!auth || typeof auth !== "object" || !("token" in auth) || typeof auth.token !== "string" || !auth.token) { + throw new Error("No token available to restore daily AIC observations"); + } + token = auth.token; + } const response = await budget.request(() => github.rest.actions.listWorkflowRunArtifacts({ owner, repo, run_id: run.id, per_page: 100 }), true, API_READ_OPERATIONS.ARTIFACT_LIST); const artifact = response.data.artifacts.find(item => item.name === AIC_SCAN_CACHE_ARTIFACT_NAME && !item.expired); if (!artifact) { diff --git a/setup/js/safe_output_redaction.cjs b/setup/js/safe_output_redaction.cjs new file mode 100644 index 00000000..a7cc9ba6 --- /dev/null +++ b/setup/js/safe_output_redaction.cjs @@ -0,0 +1,177 @@ +// @ts-check + +const path = require("path"); +const tools = require("./safe_outputs_tools.json"); +const { redactArtifactMaskedValues } = require("./add_mask_redaction.cjs"); +const { ERR_VALIDATION } = require("./error_codes.cjs"); + +const schemas = new Map(tools.map(tool => [tool.name, tool.inputSchema])); +const controlKeys = new Set([ + ...schemas.keys(), + ...Array.from(schemas.keys(), name => name.replace(/_/g, "-")), + ...tools.flatMap(tool => Object.keys(tool.inputSchema.properties || {})), + "max", + "min", + "side", + "allowed_events", + "allowed-events", + "defaultMax", + "fields", + "customValidation", + "dataEnabled", + "dataSchema", + "collapseData", + "required", + "type", + "typeHint", + "sanitize", + "maxLength", + "minLength", + "positiveInteger", + "optionalPositiveInteger", + "allowAuto", + "allowNull", + "allowEmpty", + "issueOrPRNumber", + "issueNumberOrTemporaryId", + "enum", + "itemType", + "itemSanitize", + "itemMaxLength", + "pattern", + "patternError", + "temporaryId", + "rejectIfOversized", + "x-strip-on-error", +]); +const publicEnums = new Set(tools.flatMap(tool => Object.values(tool.inputSchema.properties || {}).flatMap(property => property.enum || []))); + +/** + * Config and validation files are consumed after the redaction step too. + * Preserve their public vocabulary, but never rewrite a constraint or target. + * @param {any} value + * @param {(text: string) => string} redactText + * @param {string} [field] + */ +function assertSafeControl(value, redactText, field = "") { + if (typeof value === "string") { + if (["type", "itemType"].includes(field) && ["string", "number", "integer", "boolean", "array", "object", "null"].includes(value)) return; + if (["enum", "allowed_events", "allowed-events"].includes(field) && publicEnums.has(value)) return; + if (redactText(value) !== value) throw new Error(`${ERR_VALIDATION}: Secret redaction would change safe-output control data`); + } else if (typeof value === "number") { + if (redactText(String(value)) !== String(value)) throw new Error(`${ERR_VALIDATION}: Secret redaction would change a safe-output constraint`); + } else if (Array.isArray(value)) { + value.forEach(item => assertSafeControl(item, redactText, field)); + } else if (value && typeof value === "object") { + for (const [key, nested] of Object.entries(value)) { + if (!controlKeys.has(key) && redactText(key) !== key) throw new Error(`${ERR_VALIDATION}: Secret redaction would change a custom safe-output control field`); + assertSafeControl(nested, redactText, key); + } + } +} + +/** + * Public schema vocabulary is not secret payload data. Only schemas shipped + * with the action can authorize preserving a key or enum, never agent files. + * @param {any} value + * @param {any} schema + * @param {(text: string) => string} redactText + * @returns {any} + */ +function redactBySchema(value, schema, redactText) { + if (typeof value === "string") return schema?.enum?.includes(value) ? value : redactText(value); + if (Array.isArray(value)) return value.map(item => redactBySchema(item, schema?.items, redactText)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => { + const property = schema?.properties && Object.hasOwn(schema.properties, key) ? schema.properties[key] : undefined; + return [property ? key : redactText(key), redactBySchema(nested, property, redactText)]; + }) + ); + } + return value; +} + +/** + * @param {any} item + * @param {(text: string) => string} redactText + * @returns {any} + */ +function redactMessage(item, redactText) { + if (!item || typeof item !== "object" || Array.isArray(item) || typeof item.type !== "string") { + throw new Error(`${ERR_VALIDATION}: Invalid safe-output message during secret redaction`); + } + const schema = schemas.get(item.type); + if (!schema && redactText(item.type) !== item.type) { + throw new Error(`${ERR_VALIDATION}: Secret redaction would change a custom safe-output type`); + } + const properties = { ...schema?.properties, type: { enum: [item.type] } }; + for (const property of Object.values(schema?.properties || {})) { + for (const alias of property["x-synonyms"] || []) properties[alias] = property; + } + // Unknown top-level fields may be custom job inputs or Claim routing data. + // Do not silently rename them and then execute a different message. + for (const key of Object.keys(item)) { + if (!Object.hasOwn(properties, key) && redactText(key) !== key) { + throw new Error(`${ERR_VALIDATION}: Secret redaction would change a custom safe-output field`); + } + } + return redactBySchema(item, { properties }, redactText); +} + +/** + * Return null for ordinary artifacts; recognized executable outputs must be + * valid JSON/JSONL or throw so processFile removes them before publication. + * @param {string} filePath + * @param {string} content + * @param {string[]} maskedValues + * @param {(text: string) => string} redactText + * @returns {string | null} + */ +function redactSafeOutputContent(filePath, content, maskedValues, redactText) { + const name = path.basename(filePath); + const control = + filePath === process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH || filePath === process.env.GH_AW_VALIDATION_CONFIG_PATH || (path.basename(path.dirname(filePath)) === "safeoutputs" && ["config.json", "validation.json"].includes(name)); + const envelope = name === "agent_output.json"; + if (!control && !envelope && name !== "safeoutputs.jsonl" && filePath !== process.env.GH_AW_SAFE_OUTPUTS) return null; + const parse = record => { + try { + return JSON.parse(record); + } catch (error) { + throw new Error(`${ERR_VALIDATION}: Invalid JSON safe-output record during secret redaction`, { cause: error }); + } + }; + if (control) { + const value = parse(content); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${ERR_VALIDATION}: Invalid safe-output control object`); + const redacted = redactArtifactMaskedValues(content, maskedValues, parsed => { + assertSafeControl(parsed, redactText); + return parsed; + }); + if (redacted !== content) throw new Error(`${ERR_VALIDATION}: Secret redaction would change a safe-output constraint`); + return content; + } + const transform = value => { + if (!envelope) return redactMessage(value, redactText); + if (!value || typeof value !== "object" || !Array.isArray(value.items)) { + throw new Error(`${ERR_VALIDATION}: Invalid agent-output envelope during secret redaction`); + } + const properties = Object.fromEntries(["errors", "collectorEmptyOutputCause", "collectorFailureCause", "collectorDriverExitCode", "collectorRetryCount", "collectorEngineErrorType"].map(key => [key, {}])); + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => + key === "items" ? [key, nested.map(item => redactMessage(item, redactText))] : [Object.hasOwn(properties, key) ? key : redactText(key), redactBySchema(nested, properties[key], redactText)] + ) + ); + }; + const redactRecord = record => { + parse(record); + return redactArtifactMaskedValues(record, maskedValues, transform); + }; + if (envelope) return redactRecord(content); + return content + .split("\n") + .map(line => (line.trim() ? redactRecord(line) : line)) + .join("\n"); +} + +module.exports = { redactSafeOutputContent }; diff --git a/setup/js/scripts/fixtures/safeoutputs.cjs b/setup/js/scripts/fixtures/safeoutputs.cjs new file mode 100644 index 00000000..6b8b189a --- /dev/null +++ b/setup/js/scripts/fixtures/safeoutputs.cjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// @ts-check + +"use strict"; + +const fs = require("node:fs"); + +const [toolName, ...args] = process.argv.slice(2); +if (toolName !== "report_incomplete" && toolName !== "missing_tool") { + throw new Error(`Unsupported test safeoutputs tool: ${toolName}`); +} +const outputPath = process.env.GH_AW_SAFE_OUTPUTS; +if (!outputPath) { + throw new Error("Test safeoutputs requires GH_AW_SAFE_OUTPUTS"); +} +/** @type {Record} */ +const item = { type: toolName }; +for (let i = 0; i < args.length; i += 2) { + const key = args[i]; + const value = args[i + 1]; + if (!/^--[a-zA-Z0-9_-]+$/.test(key) || value === undefined || key === "--type") { + throw new Error(`Invalid test safeoutputs argument: ${key}`); + } + item[key.slice(2)] = value; +} +fs.appendFileSync(outputPath, JSON.stringify(item) + "\n"); diff --git a/setup/js/scripts/test_safeoutputs_setup.mjs b/setup/js/scripts/test_safeoutputs_setup.mjs new file mode 100644 index 00000000..90f68a02 --- /dev/null +++ b/setup/js/scripts/test_safeoutputs_setup.mjs @@ -0,0 +1,18 @@ +import { afterAll } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-safeoutputs-")); +const cli = path.join(tempDir, "safeoutputs"); +fs.copyFileSync(fileURLToPath(new URL("./fixtures/safeoutputs.cjs", import.meta.url)), cli); +fs.chmodSync(cli, 0o700); + +// A local output path does not rebind the real CLI's production MCP gateway. +// Isolate both the CLI override and PATH for tests and their subprocesses. +process.env.GH_AW_SAFEOUTPUTS_CLI = cli; +process.env.PATH = `${tempDir}${path.delimiter}${process.env.PATH || ""}`; +process.env.GH_AW_SAFE_OUTPUTS = path.join(tempDir, "safeoutputs.jsonl"); + +afterAll(() => fs.rmSync(tempDir, { recursive: true, force: true })); diff --git a/setup/js/start_mcp_gateway.cjs b/setup/js/start_mcp_gateway.cjs index d9fea7dc..ea637a0d 100644 --- a/setup/js/start_mcp_gateway.cjs +++ b/setup/js/start_mcp_gateway.cjs @@ -83,8 +83,10 @@ async function retryGatewayStartup(probe, retryConfig, deadline, label, shouldRe throw err; } delayMs = Math.min(delayMs * retryConfig.backoffMultiplier, retryConfig.maxDelayMs); - const sleepMs = Math.min(delayMs, deadline - nowMs()); - core.warning(`${label}: retrying after attempt ${attempt} in ${sleepMs}ms`); + const sleepMs = Math.min(Math.ceil(delayMs), deadline - nowMs()); + if (attempt === 1 || (attempt & (attempt - 1)) === 0) { + core.warning(`${label}: retrying after attempt ${attempt} in ${sleepMs}ms`); + } await sleep(sleepMs); } } @@ -114,7 +116,7 @@ const MCP_GATEWAY_HEALTH_RETRY_ENV = Object.freeze({ /** Default MCP backend startup timeout the retry budget must outlast. */ const MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS = 120_000; -/** Upper bound on configurable health-check attempts, to keep the wait finite. */ +/** Upper bound on explicit and automatically sized health-check attempts. */ const MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT = 10_000; /** @@ -131,24 +133,25 @@ const MCP_GATEWAY_HEALTH_REGISTRATION_MARGIN_MS = 25_000; * @param {number} defaultValue * @param {NodeJS.ProcessEnv} env * @param {{ integer?: boolean }} [options] - * @returns {number} + * @returns {{ value: number, wasExplicit: boolean }} */ function readPositiveEnvNumber(envName, defaultValue, env, options = {}) { const raw = env[envName]; if (raw === undefined || !raw.trim()) { - return defaultValue; + return { value: defaultValue, wasExplicit: false }; } const parsed = Number(raw.trim()); const isValid = Number.isFinite(parsed) && parsed > 0 && (!options.integer || Number.isSafeInteger(parsed)); if (!isValid) { core.warning(`Ignoring invalid ${envName}='${raw}': expected a positive ${options.integer ? "integer" : "number"}, using ${defaultValue}`); - return defaultValue; + return { value: defaultValue, wasExplicit: false }; } - return parsed; + return { value: parsed, wasExplicit: true }; } /** - * Computes the cumulative startup retry delay before exhausting its attempts. + * Computes a lower bound on the cumulative startup retry delay before exhausting + * its attempts. Actual sleeps round up to avoid Node truncating fractional delays. * The retry loop applies the backoff multiplier before its first sleep, * so the first retry waits initialDelayMs * backoffMultiplier. * @@ -156,13 +159,20 @@ function readPositiveEnvNumber(envName, defaultValue, env, options = {}) { * @returns {number} */ function computeHealthRetryBudgetMs(config) { - let delayMs = config.initialDelayMs; - let budgetMs = 0; - for (let retry = 1; retry < config.maxTotalAttempts; retry += 1) { - delayMs = Math.min(delayMs * config.backoffMultiplier, config.maxDelayMs); - budgetMs += delayMs; + const retries = config.maxTotalAttempts - 1; + if (retries <= 0) { + return 0; + } + if (config.backoffMultiplier === 1) { + return Math.min(config.initialDelayMs, config.maxDelayMs) * retries; } - return budgetMs; + + // Sum the exponential ramp and capped tail without iterating over potentially + // millions of attempts. expm1 preserves precision for multipliers close to 1. + const logMultiplier = Math.log(config.backoffMultiplier); + const rampRetries = Math.min(retries, Math.max(0, Math.ceil(Math.log(config.maxDelayMs / config.initialDelayMs) / logMultiplier) - 1)); + const rampBudgetMs = rampRetries === 0 ? 0 : (config.initialDelayMs * config.backoffMultiplier * Math.expm1(rampRetries * logMultiplier)) / (config.backoffMultiplier - 1); + return rampBudgetMs + (retries - rampRetries) * config.maxDelayMs; } /** @@ -173,11 +183,13 @@ function computeHealthRetryBudgetMs(config) { * @returns {{ maxTotalAttempts: number, initialDelayMs: number, maxDelayMs: number, backoffMultiplier: number, backendStartupTimeoutMs: number, retryBudgetMs: number }} */ function resolveGatewayHealthRetryConfig(env = process.env) { - let maxTotalAttempts = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxTotalAttempts, env, { integer: true }); - const initialDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.initialDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.initialDelayMs, env, { integer: true }); - let maxDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxDelayMs, env, { integer: true }); - let backoffMultiplier = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backoffMultiplier, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.backoffMultiplier, env); - const backendStartupTimeoutMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backendStartupTimeoutMs, MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS, env, { integer: true }); + const maxAttemptsOverride = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxTotalAttempts, env, { integer: true }); + let maxTotalAttempts = maxAttemptsOverride.value; + const hasExplicitAttempts = maxAttemptsOverride.wasExplicit; + const initialDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.initialDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.initialDelayMs, env, { integer: true }).value; + let maxDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxDelayMs, env, { integer: true }).value; + let backoffMultiplier = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backoffMultiplier, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.backoffMultiplier, env).value; + const backendStartupTimeoutMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backendStartupTimeoutMs, MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS, env, { integer: true }).value; if (maxTotalAttempts > MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT) { core.warning(`${MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts}=${maxTotalAttempts} exceeds the supported maximum, using ${MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT}`); @@ -199,9 +211,25 @@ function resolveGatewayHealthRetryConfig(env = process.env) { } const config = { maxTotalAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, backendStartupTimeoutMs }; - const retryBudgetMs = computeHealthRetryBudgetMs(config); - const requiredBudgetMs = backendStartupTimeoutMs + MCP_GATEWAY_HEALTH_REGISTRATION_MARGIN_MS; + let retryBudgetMs = computeHealthRetryBudgetMs(config); + if (!hasExplicitAttempts && retryBudgetMs < requiredBudgetMs) { + const firstDelayMs = Math.min(initialDelayMs * backoffMultiplier, maxDelayMs); + let lower = maxTotalAttempts; + // Bound automatic sizing to prevent very frequent polling from exploding. + let upper = MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT; + while (lower < upper) { + const middle = lower + Math.floor((upper - lower) / 2); + if (computeHealthRetryBudgetMs({ ...config, maxTotalAttempts: middle }) < requiredBudgetMs) { + lower = middle + 1; + } else { + upper = middle; + } + } + config.maxTotalAttempts = lower; + retryBudgetMs = computeHealthRetryBudgetMs(config); + } + if (retryBudgetMs < requiredBudgetMs) { core.warning( `MCP gateway health retry budget is ${Math.round(retryBudgetMs / 1000)}s, which is below the ${Math.round(requiredBudgetMs / 1000)}s needed for the ${Math.round(backendStartupTimeoutMs / 1000)}s backend startup timeout plus cleanup and registration; the health check may give up before the gateway is ready` @@ -1113,7 +1141,8 @@ async function main() { }, healthRetryConfig, startupDeadline, - "MCP gateway health check" + "MCP gateway health check", + () => isProcessAlive(gatewayPid) ); succeeded = true; } catch { diff --git a/setup/js/types/unified_session.d.ts b/setup/js/types/unified_session.d.ts index c76e165b..d1f8c5fd 100644 --- a/setup/js/types/unified_session.d.ts +++ b/setup/js/types/unified_session.d.ts @@ -352,6 +352,14 @@ export interface ExecutionResultData { finishedAt?: JsonValue; } +/** Conclusion's dependency snapshot, not the still-running conclusion job. */ +export interface WorkflowJobResultData { + /** @pattern ^[A-Za-z_][A-Za-z0-9_-]*$ */ + jobId: string; + result: "success" | "failure" | "cancelled" | "skipped"; + durationMs?: SessionCount; +} + export interface DailyAICDecisionData { status: "disabled" | "skipped" | "under_budget" | "exceeded" | "structural_error" | "transient_error"; exceeded?: boolean; @@ -453,6 +461,7 @@ export interface CollectionData { /** Known essential payloads. Unknown native extensions remain open. */ export interface UnifiedSessionEventDataMap { + "workflow.job_result": WorkflowJobResultData; "model.inventory": ModelInventoryData; "skill.inventory": SkillInventoryData; "skill.conflict": SkillConflictData; diff --git a/setup/js/unified_session.cjs b/setup/js/unified_session.cjs index 79fa3170..e5760220 100644 --- a/setup/js/unified_session.cjs +++ b/setup/js/unified_session.cjs @@ -137,10 +137,10 @@ function parseEngineSession(content, engine) { /** * Collect only known runtime directories; never follow artifact symlinks. * Replicated firewall files use logs > audit > legacy precedence, including empty files. - * @param {{rootDir?: string, engine?: string, warn?: (message: string) => void, dailyAIC?: NodeJS.ProcessEnv}} [options] + * @param {{rootDir?: string, engine?: string, warn?: (message: string) => void, dailyAIC?: NodeJS.ProcessEnv, jobResults?: string, jobDurations?: Record}} [options] * @returns {{events: import("./types/unified_session").UnifiedSession, maskedValues: string[]}} */ -function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message => console.warn(message), dailyAIC = process.env } = {}) { +function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message => console.warn(message), dailyAIC = process.env, jobResults = process.env.GH_AW_JOB_RESULTS, jobDurations = {} } = {}) { let currentRunId = dailyAIC.GITHUB_RUN_ID; let currentRunAttempt = dailyAIC.GITHUB_RUN_ATTEMPT; /** @type {SessionSource[]} */ @@ -622,6 +622,33 @@ function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message } sources.push({ component: "guardrail", phase: "activation", path: "usage/aw_session.jsonl", events: [{ type: "guardrail.daily_aic", data }] }); } + if (jobResults) { + const file = path.join(rootDir, "usage/aw_session.jsonl"); + let needs; + try { + needs = JSON.parse(jobResults); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + report(file, "invalid_job_results", undefined); + } + if (needs !== undefined) { + if (!needs || typeof needs !== "object" || Array.isArray(needs)) { + report(file, "invalid_job_results", undefined); + } else { + /** @type {SessionEvent[]} */ + const events = []; + for (const [jobId, job] of Object.entries(needs).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))) { + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(jobId) || !job || typeof job !== "object" || Array.isArray(job) || !["success", "failure", "cancelled", "skipped"].includes(job.result)) { + report(file, "invalid_job_result", undefined); + continue; + } + const durationMs = jobDurations[jobId]; + events.push({ type: "workflow.job_result", data: { jobId, result: job.result, ...(job.result !== "skipped" && Number.isSafeInteger(durationMs) && durationMs >= 0 ? { durationMs } : {}) } }); + } + if (events.length) sources.push({ component: "workflow", phase: "conclusion", path: "usage/aw_session.jsonl", events }); + } + } + } const persistedWarnings = sources.flatMap(source => source.events.filter(event => event.type === "session.collection_warning")); const coverageWarnings = [...warnings]; for (const event of persistedWarnings) { @@ -650,7 +677,7 @@ function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message } /** - * @param {{rootDir?: string, engine?: string, outputPath?: string, warn?: (message: string) => void}} [options] + * @param {{rootDir?: string, engine?: string, outputPath?: string, warn?: (message: string) => void, jobDurations?: Record}} [options] * @returns {SessionEvent[]} */ function writeUnifiedSession(options = {}) { @@ -665,9 +692,10 @@ function writeUnifiedSession(options = {}) { } } -/** @param {{rootDir?: string, engine?: string, outputPath?: string}} [options] @returns {Promise} */ +/** @param {{rootDir?: string, engine?: string, outputPath?: string, jobDurations?: Record}} [options] @returns {Promise} */ async function main(options = {}) { - writeUnifiedSession(options); + const jobDurations = options.jobDurations ?? (await require("./workflow_job_durations.cjs").collectJobDurations({ warn: message => global.core.warning(message) })); + writeUnifiedSession({ ...options, jobDurations }); const { publishUnifiedSessionSummary } = require("./unified_session_render.cjs"); await publishUnifiedSessionSummary(options.outputPath ?? path.join(options.rootDir ?? "/tmp/gh-aw", "usage/aw_session.jsonl")); } diff --git a/setup/js/unified_session_payload.cjs b/setup/js/unified_session_payload.cjs index f5d7e1da..979708db 100644 --- a/setup/js/unified_session_payload.cjs +++ b/setup/js/unified_session_payload.cjs @@ -189,6 +189,7 @@ const EVENT_FIELDS = { }, ...COPILOT_WORKFLOW_EVENT_FIELDS, "session.format": { version: ["version"] }, + "workflow.job_result": { jobId: ["jobId"], result: ["result"], durationMs: ["durationMs"] }, "agent.execution": { categories: ["categories"], errorCodes: ["errorCodes"], errorTypes: ["errorTypes"], exitCode: ["exitCode", "exit_code"] }, "skill.invoked": { name: ["name"], path: ["path"], source: ["source"], trigger: ["trigger"], model: ["model"], invokedAtTurn: ["invokedAtTurn"] }, "skill.context_delivered_ref": { interactionId: ["interactionId"], source: ["source"], contentId: ["contentId"] }, diff --git a/setup/js/unified_session_render.cjs b/setup/js/unified_session_render.cjs index e30d4cec..8ebaff1c 100644 --- a/setup/js/unified_session_render.cjs +++ b/setup/js/unified_session_render.cjs @@ -45,6 +45,7 @@ const RUNTIME_TYPES = new Set([ "execution.result", "detection.result", "workflow.info", + "workflow.job_result", "github_api.rate_limit", "guardrail.daily_aic", "otel.span", @@ -106,6 +107,8 @@ function eventDetail(event) { ); } switch (event.type) { + case "workflow.job_result": + return fields(data, ["jobId", "result", "durationMs"]); case "session.format": return `version=${inline(data.version)}`; case "session.init": diff --git a/setup/js/vitest.config.mjs b/setup/js/vitest.config.mjs index 4b914959..435c018c 100644 --- a/setup/js/vitest.config.mjs +++ b/setup/js/vitest.config.mjs @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", globals: true, + setupFiles: ["./scripts/test_safeoutputs_setup.mjs"], include: ["**/*.test.{js,cjs}"], exclude: ["**/frontmatter_hash_github_api.test.cjs", ...configDefaults.exclude], testTimeout: 10000, diff --git a/setup/js/work_queue_control_adapter_checks.cjs b/setup/js/work_queue_control_adapter_checks.cjs index b6e46749..5812bf1a 100644 --- a/setup/js/work_queue_control_adapter_checks.cjs +++ b/setup/js/work_queue_control_adapter_checks.cjs @@ -815,7 +815,9 @@ async function verifyCompiledDispatchIdentity(input) { assert.notEqual(parameters.ref, fixture.dispatcherContext.sha); return { status: 200, data: { workflow_run_id: "42", run_url: "https://api.github.com/repos/owner/repo/actions/runs/42", html_url: "https://github.com/owner/repo/actions/runs/42" } }; }, - getWorkflowRun: async () => { + getWorkflowRunAttempt: async parameters => { + assert.equal(parameters.run_id, "42"); + assert.equal(parameters.attempt_number, 1); const dispatch = [...fixture.state.dispatches.values()][0]; const data = { ...fixture.nativeRun(), display_title: `gh-aw work-queue ${dispatch.dispatch_id}` }; if (mode === "wrong-worker") data.actor = { id: "11" }; diff --git a/setup/js/workflow_job_durations.cjs b/setup/js/workflow_job_durations.cjs new file mode 100644 index 00000000..2691985f --- /dev/null +++ b/setup/js/workflow_job_durations.cjs @@ -0,0 +1,96 @@ +// @ts-check +/// + +/** + * Only uniquely matched completed jobs can supply a dependency's duration. + * Matrix and reusable-workflow groups do not have a single job duration. + * @typedef {{name: string, status?: string, conclusion?: string | null, started_at?: string | null, completed_at?: string | null, run_attempt?: number}} JobTiming + * @param {{githubClient?: typeof github, runContext?: typeof context, env?: NodeJS.ProcessEnv, jobs?: JobTiming[], warn: (message: string) => void}} options + * @returns {Promise>} + */ +async function collectJobDurations({ githubClient = global.github, runContext = global.context, env = process.env, jobs: providedJobs, warn }) { + if (!env.GH_AW_JOB_RESULTS) return {}; + if (env.GH_AW_JOB_DURATIONS) { + try { + const cached = JSON.parse(env.GH_AW_JOB_DURATIONS); + if ( + cached && + typeof cached === "object" && + !Array.isArray(cached) && + Object.entries(cached).every(([id, duration]) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(id) && typeof duration === "number" && Number.isSafeInteger(duration) && duration >= 0) + ) + return cached; + warn("Unified session: invalid cached job durations; reading Actions job timing"); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + warn("Unified session: invalid cached job durations; reading Actions job timing"); + } + } + let needs; + let names; + let eligible; + try { + needs = JSON.parse(env.GH_AW_JOB_RESULTS); + names = JSON.parse(env.GH_AW_JOB_DISPLAY_NAMES || "{}"); + eligible = JSON.parse(env.GH_AW_JOB_TIMING_ELIGIBLE || "{}"); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + warn("Unified session: job durations unavailable (invalid job metadata)"); + return {}; + } + if (!needs || typeof needs !== "object" || Array.isArray(needs) || !names || typeof names !== "object" || Array.isArray(names) || !eligible || typeof eligible !== "object" || Array.isArray(eligible)) { + warn("Unified session: job durations unavailable (invalid job metadata)"); + return {}; + } + if (!Object.keys(needs).some(id => eligible[id] === true)) return {}; + const attempt = Number(env.GITHUB_RUN_ATTEMPT); + if (!githubClient || !runContext?.repo || !Number.isSafeInteger(attempt) || attempt < 1) { + warn("Unified session: job durations unavailable (missing Actions run context)"); + return {}; + } + /** @type {JobTiming[]} */ + const jobs = providedJobs ?? []; + try { + for (let page = 1; providedJobs === undefined; page++) { + const response = await githubClient.rest.actions.listJobsForWorkflowRunAttempt({ + ...runContext.repo, + run_id: runContext.runId, + attempt_number: attempt, + per_page: 100, + page, + }); + jobs.push(...response.data.jobs); + if (response.data.jobs.length < 100) break; + } + } catch (error) { + // API errors can include request details; never log the response or metadata. + warn("Unified session: job durations unavailable (Actions API request failed)"); + return {}; + } + /** @type {Record} */ + const durations = Object.create(null); + const knownIds = [...new Set([...Object.keys(needs), ...Object.keys(names)])]; + const displayName = id => (typeof names[id] === "string" && names[id] ? names[id] : id); + const matchesName = (name, display) => name === display || name.startsWith(`${display} (`) || name.startsWith(`${display} / `); + for (const [jobId, dependency] of Object.entries(needs)) { + if (eligible[jobId] !== true || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(jobId) || !["success", "failure", "cancelled"].includes(dependency?.result)) continue; + const matches = jobs.filter(job => job.name === displayName(jobId) && (job.run_attempt === undefined || job.run_attempt === attempt)); + const ambiguous = knownIds.some(id => id !== jobId && matchesName(displayName(jobId), displayName(id))); + if (matches.length !== 1 || ambiguous) { + warn(`Unified session: job duration unavailable for ${jobId} (no unique Actions job match)`); + continue; + } + const job = matches[0]; + const started = typeof job.started_at === "string" ? Date.parse(job.started_at) : NaN; + const completed = typeof job.completed_at === "string" ? Date.parse(job.completed_at) : NaN; + const durationMs = completed - started; + if (job.status !== "completed" || job.conclusion === "skipped" || !Number.isSafeInteger(durationMs) || durationMs < 0) { + warn(`Unified session: job duration unavailable for ${jobId} (missing or invalid completed-job timing)`); + continue; + } + durations[jobId] = durationMs; + } + return durations; +} + +module.exports = { collectJobDurations }; diff --git a/setup/session_parsers.go b/setup/session_parsers.go index 842a8f1b..7bfd7474 100644 --- a/setup/session_parsers.go +++ b/setup/session_parsers.go @@ -9,6 +9,7 @@ import "embed" //go:embed js/steering_helpers.cjs js/jsonl_helpers.cjs //go:embed js/skill_inventory.cjs //go:embed js/unified_session_order.cjs js/unified_session_otel.cjs +//go:embed js/workflow_job_durations.cjs //go:embed js/mcp_configuration.cjs js/symlink_guard.cjs //go:embed js/mcp_rate_limit.cjs //go:embed js/parse_claude_log.cjs js/parse_codex_log.cjs js/parse_copilot_log.cjs js/parse_gemini_log.cjs @@ -23,7 +24,7 @@ import "embed" //go:embed js/pi_session.cjs js/pi_session_redaction.cjs js/session_artifact.cjs //go:embed js/log_parser_bootstrap.cjs js/log_parser_format.cjs js/log_parser_shared.cjs //go:embed js/log_parser_step_summary_builder.cjs js/markdown_unfencing.cjs -//go:embed js/add_mask_redaction.cjs js/redact_secrets.cjs js/safe_outputs_config_redact.cjs js/safe_output_manifest.cjs +//go:embed js/add_mask_redaction.cjs js/json_string_scanner.cjs js/mask_value_matcher.cjs js/redact_secrets.cjs js/safe_output_redaction.cjs js/safe_outputs_tools.json js/safe_outputs_config_redact.cjs js/safe_output_manifest.cjs //go:embed js/otlp_header_helpers.cjs //go:embed js/work_queue_claim_scope.cjs js/work_queue_codec.cjs js/work_queue_resource_scope.cjs //go:embed js/work_queue_host.cjs diff --git a/setup/setup.sh b/setup/setup.sh index c31552d6..fb48eb01 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -265,6 +265,8 @@ MCP_SCRIPTS_FILES=( "shim.cjs" "mcp-scripts-runner.cjs" "add_mask_redaction.cjs" + "json_string_scanner.cjs" + "mask_value_matcher.cjs" "aw_context.cjs" "diagnostic_sanitization.cjs" "experiment_helpers.cjs" @@ -278,6 +280,8 @@ MCP_SCRIPTS_FILES=( "pi_session_redaction.cjs" "otlp_header_helpers.cjs" "redact_secrets.cjs" + "safe_output_redaction.cjs" + "safe_outputs_tools.json" "safe_outputs_config_redact.cjs" "repo_helpers.cjs" "safe_output_helpers.cjs" @@ -367,6 +371,7 @@ SAFE_OUTPUTS_FILES=( "safe_outputs_bootstrap.cjs" "safe_outputs_tools_loader.cjs" "safe_outputs_config.cjs" + "safe_output_redaction.cjs" "safe_outputs_config_redact.cjs" "safe_outputs_handlers.cjs" "ledger_append.cjs" @@ -437,6 +442,7 @@ SAFE_OUTPUTS_FILES=( "firewall_blocked_domains.cjs" "render_template.cjs" "ai_credits_context.cjs" + "harness_error_patterns.cjs" "numeric_limits.cjs" "is_truthy.cjs" "template_branch.cjs" @@ -458,6 +464,8 @@ SAFE_OUTPUTS_FILES=( "experiment_helpers.cjs" "safe_output_helpers.cjs" "add_mask_redaction.cjs" + "json_string_scanner.cjs" + "mask_value_matcher.cjs" "pi_session_redaction.cjs" "staged_preview.cjs" "finish_work_queue_claim.cjs"