diff --git a/setup/js/agent_execution.cjs b/setup/js/agent_execution.cjs index 3b0c5702..e3c583ac 100644 --- a/setup/js/agent_execution.cjs +++ b/setup/js/agent_execution.cjs @@ -128,6 +128,41 @@ function agentErrorDiagnosticText(content) { return [...diagnostics, ...collectNativeErrorEvidence(errors).diagnostics].join("\n"); } +const ENGINE_ERROR_SUMMARIES = Object.freeze({ + authentication_failed: "Authentication failed", + inference_access_error: "Inference access denied by policy settings", + mcp_policy_error: "MCP servers were blocked by policy", + agentic_engine_timeout: "Engine timed out", + model_not_supported_error: "Requested model is not supported", + http_400_response_error: "Provider returned HTTP 400 Bad Request", + capi_quota_exceeded_error: "Provider quota or rate limit exceeded", + capi_server_error: "Provider server error after retries", + invocation_cap_exceeded: "Maximum LLM invocations exceeded", + max_cache_misses_exceeded: "Maximum consecutive cache misses exceeded", + missing_model_pricing_error: "Model has no AI credits pricing", + shell_expansion_guard_rejected: "Shell expansion guard rejected a command", + max_ai_credits_exceeded: "Maximum AI credits exceeded", + effective_tokens_limit_exceeded: "Effective token limit exceeded", + permission_denied_limit_exceeded: "Permission denial limit exceeded", + model_policy_violation: "Model policy violation", + awf_api_proxy_blocking_requests: "AWF API proxy blocked requests", + goal_already_active: "Session already has an active goal", + sandbox_runtime_crash: "Sandbox runtime crashed", +}); + +/** + * Preserve actionable error classes without publishing raw log lines or payloads. + * @param {string} content + * @returns {string} + */ +function agentErrorSummaryText(content) { + const execution = collectAgentExecution({ content }); + return (execution?.data.categories || []) + .filter(category => Object.hasOwn(ENGINE_ERROR_SUMMARIES, category)) + .map(category => `Engine error: ${ENGINE_ERROR_SUMMARIES[category]} (${category})`) + .join("\n"); +} + /** * @param {any[]} errors */ @@ -234,4 +269,4 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi return { type: "agent.execution", data }; } -module.exports = { collectAgentExecution, agentErrorDiagnosticText, validateAgentExecution, validateAgentExitCode, parseAgentExitCode, isAgentExecutionEvent }; +module.exports = { collectAgentExecution, agentErrorDiagnosticText, agentErrorSummaryText, validateAgentExecution, validateAgentExitCode, parseAgentExitCode, isAgentExecutionEvent }; diff --git a/setup/js/claude_harness.cjs b/setup/js/claude_harness.cjs index 3d27ace1..4829e163 100644 --- a/setup/js/claude_harness.cjs +++ b/setup/js/claude_harness.cjs @@ -63,6 +63,7 @@ const { fetchAWFReflect, fetchModelsFromUrl, normalizeReflectProviderName, + REFLECT_PROVIDER_ALIASES, resolveProviderEndpointFromReflect, } = require("./awf_reflect.cjs"); const { emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs, hasTerminalSafeOutput } = require("./safeoutputs_cli.cjs"); @@ -70,7 +71,7 @@ const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractD const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError, parseAICreditsExceededProxyRejection } = require("./harness_retry_guard.cjs"); const { isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness_crash_signals.cjs"); const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); -const { applyModelFallback } = require("./model_fallback.cjs"); +const { applyModelFallback, normalizeClaudeModel, normalizeClaudeModelArgs } = require("./model_fallback.cjs"); const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); // Pattern to detect Anthropic API overload errors (HTTP 529). @@ -364,19 +365,27 @@ function stripContinueArgs(args) { /** * Build Claude child process env with provider endpoint overrides resolved from /reflect. + * @param {import("./awf_reflect.cjs").ReflectData | null | undefined} reflectData + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [logger] * @returns {Promise} */ -async function buildClaudeChildEnv(reflectData) { - const childEnv = { ...process.env }; +async function buildClaudeChildEnv(reflectData, env = process.env, logger = log) { + const childEnv = { ...env }; applyClaudeRuntimeTimeouts(childEnv); - applyModelFallback(childEnv, "ANTHROPIC_MODEL", log); - const provider = normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"); - if (reflectData) { - const resolved = resolveProviderEndpointFromReflect({ provider, reflectData, logger: log }); - if (resolved && resolved.baseUrl) { - childEnv.ANTHROPIC_BASE_URL = resolved.baseUrl; - log(`configured ANTHROPIC_BASE_URL from /reflect for provider=${provider}: ${resolved.baseUrl}`); - } + applyModelFallback(childEnv, "ANTHROPIC_MODEL", logger); + const provider = normalizeReflectProviderName(env.GH_AW_LLM_PROVIDER, "anthropic"); + const copilotProvider = REFLECT_PROVIDER_ALIASES.github.has(provider); + if (childEnv.ANTHROPIC_MODEL) { + childEnv.ANTHROPIC_MODEL = normalizeClaudeModel(childEnv.ANTHROPIC_MODEL, provider, childEnv); + } + const resolved = reflectData ? resolveProviderEndpointFromReflect({ provider: copilotProvider ? "github" : provider, reflectData, logger }) : null; + if (copilotProvider && (!resolved || !REFLECT_PROVIDER_ALIASES.github.has(normalizeReflectProviderName(resolved.endpointProvider)))) { + throw new Error("Claude GitHub Copilot inference requires a configured Copilot endpoint from AWF /reflect; keep the agent sandbox enabled and configure permissions.copilot-requests: write or COPILOT_GITHUB_TOKEN"); + } + if (resolved?.baseUrl) { + childEnv.ANTHROPIC_BASE_URL = resolved.baseUrl; + logger(`configured ANTHROPIC_BASE_URL from /reflect for provider=${provider}: ${resolved.baseUrl}`); } return childEnv; } @@ -405,7 +414,8 @@ async function main() { try { const resolved = resolveClaudePromptFileArgs(args); prompt = resolved.prompt; - const capabilities = claudeBareCapabilities(claudeRepositoryEditPolicy(stripContinueArgs(resolved.args), process.env)); + const modelArgs = normalizeClaudeModelArgs(resolved.args, normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"), process.env); + const capabilities = claudeBareCapabilities(claudeRepositoryEditPolicy(stripContinueArgs(modelArgs), process.env)); initialArgs = capabilities.args; pluginDir = capabilities.pluginDir; } catch (err) { @@ -752,6 +762,7 @@ if (typeof module !== "undefined" && module.exports) { resolveRetryConfig, resolveStartupRetryLimit, applyModelFallback, + buildClaudeChildEnv, }; } diff --git a/setup/js/diagnostic_sanitization.cjs b/setup/js/diagnostic_sanitization.cjs new file mode 100644 index 00000000..3dd38a1f --- /dev/null +++ b/setup/js/diagnostic_sanitization.cjs @@ -0,0 +1,46 @@ +// @ts-check + +const { collectArtifactSecretValues, redactManifestValue } = require("./safe_output_manifest.cjs"); +const { applyAddMaskRedaction } = require("./add_mask_redaction.cjs"); +const { sanitizeContent } = require("./sanitize_content.cjs"); + +/** + * @typedef {Object} DiagnosticOptions + * @property {string[]} [secrets] + * @property {string[]} [maskedValues] + * @property {number} [maxLength] + */ + +/** + * Exact masks must run before patterns that can consume only a secret's prefix. + * @param {string} text + * @param {DiagnosticOptions} [options] + * @returns {string} + */ +function redactDiagnosticText(text, { secrets = collectArtifactSecretValues(), maskedValues = [] } = {}) { + const masks = [...secrets, ...maskedValues].sort((a, b) => b.length - a.length); + return String(redactManifestValue(applyAddMaskRedaction(text, masks), [])); +} + +/** + * Bound and fully normalize diagnostics before callers choose Markdown fences. + * @param {string} text + * @param {DiagnosticOptions} [options] + * @returns {string} + */ +function redactAndBoundDiagnostics(text, options = {}) { + const maxLength = options.maxLength ?? 8000; + const normalize = value => { + // Entity decoding and control/comment removal can expose another encoded layer. + // Stabilize the bounded text so later sanitization cannot create a new fence. + for (;;) { + const sanitized = sanitizeContent(value, { maxLength }); + if (sanitized === value) return sanitized; + value = sanitized; + } + }; + const normalized = normalize(redactDiagnosticText(text, options)); + return normalize(redactDiagnosticText(normalized, options)); +} + +module.exports = { redactDiagnosticText, redactAndBoundDiagnostics }; diff --git a/setup/js/empty_output_outcome.cjs b/setup/js/empty_output_outcome.cjs index c027bcc4..953268ef 100644 --- a/setup/js/empty_output_outcome.cjs +++ b/setup/js/empty_output_outcome.cjs @@ -3,12 +3,12 @@ const fs = require("fs"); const path = require("path"); const { collectUnifiedSession } = require("./unified_session.cjs"); -const { agentErrorDiagnosticText } = require("./agent_execution.cjs"); +const { agentErrorDiagnosticText, agentErrorSummaryText } = require("./agent_execution.cjs"); const { extractDeniedCommands } = require("./permission_denied_helpers.cjs"); const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); -const { collectArtifactSecretValues, redactManifestValue } = require("./safe_output_manifest.cjs"); -const { collectAddMaskedValues, redactMaskedValues } = require("./add_mask_redaction.cjs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); +const { collectArtifactSecretValues } = require("./safe_output_manifest.cjs"); +const { collectAddMaskedValues } = require("./add_mask_redaction.cjs"); +const { redactDiagnosticText, redactAndBoundDiagnostics } = require("./diagnostic_sanitization.cjs"); // Keep cause titles and the category allow-list in sync; schema and docs list these causes too. const EMPTY_OUTPUT_CAUSES = Object.freeze({ @@ -45,7 +45,7 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { } maskedValues.push(...collectAddMaskedValues(stdio)); const secrets = collectArtifactSecretValues(); - const redact = value => redactMaskedValues(String(redactManifestValue(value, secrets)), maskedValues); + const redact = value => redactDiagnosticText(String(value), { secrets, maskedValues }); const redactJson = value => JSON.stringify(value, (_key, nested) => (typeof nested === "string" ? redact(nested) : nested)); let safeoutputsCliError = false; try { @@ -117,11 +117,13 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") { } }) .join("\n"); + // Extract denied commands without copying harness configuration or transcript data. const attributedDiagnostics = agentErrorDiagnosticText(safeStdio); for (const command of extractDeniedCommands(attributedDiagnostics)) diagnostics.add(`Permission denied: ${command}`); - if (attributedDiagnostics) diagnostics.add(attributedDiagnostics); + const engineSummary = agentErrorSummaryText(safeStdio); + if (engineSummary) diagnostics.add(engineSummary); const details = [...diagnostics].slice(0, 20).join("\n"); - const sanitized = sanitizeContent(redact(details), { maxLength: 8000 }); + const sanitized = redactAndBoundDiagnostics(details, { secrets, maskedValues }); return { type: "report_incomplete", reason, diff --git a/setup/js/generate_aw_info.cjs b/setup/js/generate_aw_info.cjs index ec778b21..b925c402 100644 --- a/setup/js/generate_aw_info.cjs +++ b/setup/js/generate_aw_info.cjs @@ -88,6 +88,7 @@ async function main(core, ctx, githubClient) { event_name: ctx.eventName, target_repo: process.env.GH_AW_INFO_TARGET_REPO || "", staged: process.env.GH_AW_INFO_STAGED === "true", + dry_run: process.env.GH_AW_INFO_DRY_RUN === "true", allowed_domains: allowedDomains, firewall_enabled: process.env.GH_AW_INFO_FIREWALL_ENABLED === "true", awf_version: process.env.GH_AW_INFO_AWF_VERSION || "", @@ -98,6 +99,9 @@ async function main(core, ctx, githubClient) { }, created_at: new Date().toISOString(), }; + if (awInfo.dry_run) { + core.warning("Dry-run disables compiler-managed GitHub mutations only. Custom scripts/jobs, agent shell commands, external MCP servers, and custom credentials are not verified read-only."); + } if (process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT === "true") { try { diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 9dcaa003..31ece7aa 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -3,6 +3,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { redactAndBoundDiagnostics } = require("./diagnostic_sanitization.cjs"); const { getDetectionCautionAlert, getFooterAgentFailureIssueMessage, getFooterAgentFailureCommentMessage, generateXMLMarker } = require("./messages.cjs"); const { renderTemplate, renderTemplateFromFile, getPromptPath, renderFilesList } = require("./messages_core.cjs"); const { getCurrentBranch } = require("./get_current_branch.cjs"); @@ -25,7 +26,7 @@ const { formatAICCredits } = require("./daily_aic_workflow_helpers.cjs"); const { formatAIC } = require("./model_costs.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp_gateway_log.cjs"); -const { readDedupedTokenUsage, TOKEN_USAGE_PATHS } = require("./parse_token_usage.cjs"); +const { readDedupedTokenUsage, getTokenUsagePaths } = require("./parse_token_usage.cjs"); const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); const { resolveFailureIssueRepo } = require("./repo_helpers.cjs"); const { GITHUB_API_VERSION } = require("./constants.cjs"); @@ -1701,13 +1702,8 @@ function buildReportIncompleteContext(items) { core.info(`Found ${messages.length} report_incomplete signal(s)`); - let context = buildWarningAlertLine("Task Could Not Be Completed", "The agent reported that the task could not be performed due to an infrastructure or tool failure.") + "\n**Reasons:**\n"; - for (const msg of messages) { - context += `- ${msg.reason}\n`; - if (msg.details) { - context += ` \n ${msg.details}\n`; - } - } + let context = buildWarningAlertLine("Task Could Not Be Completed", "The agent reported that the task could not be performed due to an infrastructure or tool failure."); + context += renderErrorDetails(messages.map(msg => [msg.reason, msg.details].filter(Boolean).join("\n")).join("\n\n")); context += "\nThis is a structured incompletion signal (`report_incomplete`), not a real task outcome. Any other safe outputs emitted alongside this signal (e.g., comments) describe the failure state, not a completed review or action.\n\n"; @@ -1923,6 +1919,18 @@ function renderPluginDiagnosticsDetails(diagnosticsLog) { return `\n\n
\nPlugin installation diagnostics\n\n${fence}\n${sanitizedDiagnostics}\n${fence}\n\n
\n`; } +/** + * Render error diagnostics as literal text with progressive disclosure. + * @param {string} diagnostics + * @param {string[]} [maskedValues] + * @returns {string} + */ +function renderErrorDetails(diagnostics, maskedValues = []) { + const sanitized = redactAndBoundDiagnostics(diagnostics, { maskedValues }); + const fence = safeMarkdownCodeFence([sanitized]); + return `\n\n
\nError details:\n\n${fence}text\n${sanitized}\n${fence}\n\n
\n\n`; +} + /** * Build a context string when MCP servers were blocked by enterprise/organization policy. * This is a persistent configuration error — retrying will not help. @@ -2296,7 +2304,7 @@ function buildEngineMaxCacheMissesExceededContext(engineLabel) { */ function readTokenUsageMarkdown() { try { - const readablePaths = TOKEN_USAGE_PATHS.filter(p => { + const readablePaths = getTokenUsagePaths().filter(p => { try { return fs.existsSync(p) && fs.statSync(p).size > 0; } catch { @@ -3051,8 +3059,7 @@ function detectEngineRateLimit429Failure() { /** * Extract terminal error messages from agent-stdio.log to surface engine failures. * First tries to match known error patterns (ERROR:, Error:, Fatal:, panic:, Reconnecting...). - * Falls back to the last non-empty lines of the log when no patterns match, so that - * even timeout or unexpected-termination failures include the final agent output. + * Never copies arbitrary log tails into reports, since they can contain secrets. * The log file is available in the conclusion job after the agent artifact is downloaded. * @returns {string} Formatted context string, or empty string if no engine failure found */ @@ -3072,7 +3079,7 @@ function buildEngineFailureContext(options = {}) { try { const exitCodeText = fs.existsSync(exitCodePath) ? fs.readFileSync(exitCodePath, "utf8").trim() : ""; - const exitDetails = /^[1-9]\d{0,2}$/.test(exitCodeText) && Number(exitCodeText) <= 255 ? `**Driver exit code:** ${exitCodeText}\n\n` : ""; + const exitDetails = /^[1-9]\d{0,2}$/.test(exitCodeText) && Number(exitCodeText) <= 255 ? renderErrorDetails(`Driver exit code: ${exitCodeText}`) : ""; if (!fs.existsSync(stdioLogPath)) { if (shellExpansionGuardRejectedFromDetection) { core.info("agent-stdio.log not found, but shell expansion guard rejection was detected — using dedicated context message"); @@ -3265,25 +3272,17 @@ function buildEngineFailureContext(options = {}) { : "**Diagnosis:** The DIFC proxy (`awmg-cli-proxy`) failed to respond (`diagnosis=unknown`). The probe exhausted its retry budget before the service became reachable.\n\n"; context += dnsDiagnosis; } - context += "\n
\nError details\n\n"; - for (const message of errorMessages) { - context += `- ${applyAddMaskRedaction(message, maskedValues)}\n`; - } - context += `\n
\n\nSee [Diagnosing AWF Failures](https://github.com/github/gh-aw-firewall/blob/main/docs/diagnosing-awf-failures.md) for troubleshooting guidance.\n\n`; + context += renderErrorDetails([...errorMessages].join("\n"), maskedValues); + context += `See [Diagnosing AWF Failures](https://github.com/github/gh-aw-firewall/blob/main/docs/diagnosing-awf-failures.md) for troubleshooting guidance.\n\n`; return context; } - let context = buildWarningAlertLine("Engine Failure", `The${engineLabel} engine terminated before producing output.`) + "\n" + exitDetails + "**Error details:**\n"; - for (const message of errorMessages) { - context += `- ${applyAddMaskRedaction(message, maskedValues)}\n`; - } - context += "\n"; - return context; + const diagnostics = [...(exitDetails ? [`Driver exit code: ${exitCodeText}`] : []), ...errorMessages].join("\n"); + return buildWarningAlertLine("Engine Failure", `The${engineLabel} engine terminated before producing output.`) + renderErrorDetails(diagnostics, maskedValues); } // AWF infrastructure lines written by the firewall/container wrapper — not produced by - // the engine itself. They must be filtered out of the fallback tail so the failure - // context surfaces actual agent output rather than container lifecycle noise + // the engine itself. Filter them out when checking whether the engine produced output // (e.g. "Container awf-squid Removed", "[WARN] Command completed with exit code: 1", // "Process exiting with code: 1"). Shared constant from log_parser_shared.cjs keeps the // pattern in sync with parse_copilot_log.cjs. @@ -3293,7 +3292,7 @@ function buildEngineFailureContext(options = {}) { // [WARN] --pids-limit/container.pidsLimit is not supported by this microVM runtime … // The Docker agent cgroup cannot be passed through, so pids.max/pids.current are unavailable. // Those continuations belong to the infrastructure line above them, so they must be - // filtered out as well — otherwise they can be reported as the "last agent output". + // filtered out as well to avoid misclassifying a startup failure. const infraContinuationLines = new Set(); { let previousWasInfra = false; @@ -3314,17 +3313,15 @@ function buildEngineFailureContext(options = {}) { } } - // Fallback: no known error patterns found — include the last non-empty lines so that - // failures caused by timeouts or unexpected terminations still surface useful context. - const TAIL_LINES = 10; + // Classify startup failures without publishing arbitrary engine output. const nonEmptyLines = lines.map((line, index) => ({ line, index })).filter(entry => entry.line.trim()); if (nonEmptyLines.length === 0) { return ""; } - // Exclude AWF infrastructure lines so the fallback displays only actual engine output. + // Exclude AWF infrastructure lines when checking for actual engine output. // `::add-mask::` command lines are runner directives, not agent output: drop them so the - // rendered tail neither leaks the masked value nor wastes a tail slot. + // directives do not count as engine output. const agentLines = nonEmptyLines .filter(entry => !INFRA_LINE_RE.test(entry.line) && !infraContinuationLines.has(entry.index) && !isAddMaskCommandLine(entry.line) && !isRecoveredNoDeferredMarkerLine(entry.index)) .map(entry => entry.line); @@ -3358,13 +3355,8 @@ function buildEngineFailureContext(options = {}) { return context; } - const tailLines = agentLines.slice(-TAIL_LINES); - core.info(`No specific error patterns found; including last ${tailLines.length} line(s) of agent-stdio.log as fallback`); - - let context = buildWarningAlertLine("Engine Failure", `The${engineLabel} engine terminated unexpectedly.`) + "\n" + exitDetails + "**Last agent output:**\n\`\`\`\n"; - context += applyAddMaskRedaction(tailLines.join("\n"), maskedValues); - context += "\n```\n\n"; - return context; + core.info("No specific error patterns found; omitting raw agent output from the failure report"); + return buildWarningAlertLine("Engine Failure", `The${engineLabel} engine terminated unexpectedly.`) + "\n" + exitDetails + "Review the workflow run logs for diagnostics. Raw agent output is omitted because it may contain secrets.\n\n"; } catch (error) { core.info(`Failed to read agent-stdio.log for engine failure context: ${getErrorMessage(error)}`); return ""; diff --git a/setup/js/model_fallback.cjs b/setup/js/model_fallback.cjs index 7ebde654..0aa1d7b1 100644 --- a/setup/js/model_fallback.cjs +++ b/setup/js/model_fallback.cjs @@ -1,5 +1,7 @@ "use strict"; +const { normalizeReflectProviderName, REFLECT_PROVIDER_ALIASES } = require("./awf_reflect.cjs"); + const MODEL_FALLBACK_ENV_VAR = "GH_AW_MODEL_FALLBACK"; function readTrimmedEnv(env, name) { @@ -59,7 +61,7 @@ function modelFlagPrefix(argument) { function normalizeCodexModel(model, provider, options = {}) { const match = /^(openai|copilot|github|github-copilot|github_models|anthropic)\/(.+)$/i.exec(model.trim()); if (!match) return model.trim(); - const normalize = value => (/^(copilot|github|github-copilot|github_models)$/.test(value.toLowerCase()) ? "github" : value.toLowerCase()); + const normalize = value => (REFLECT_PROVIDER_ALIASES.github.has(normalizeReflectProviderName(value)) ? "github" : normalizeReflectProviderName(value)); if (normalize(match[1]) !== normalize(provider.trim().toLowerCase())) { const env = options.env ?? process.env; if (env.GH_AW_LLM_PROVIDER_EXPLICIT === "1" && ["openai", "github"].includes(normalize(match[1]))) { @@ -71,6 +73,30 @@ function normalizeCodexModel(model, provider, options = {}) { return match[2]; } +/** @param {string} model @param {string} provider @param {NodeJS.ProcessEnv} env @returns {string} */ +function normalizeClaudeModel(model, provider, env) { + const match = /^copilot\/(.+)$/i.exec(model.trim()); + if (!match) return model.trim(); + if (!REFLECT_PROVIDER_ALIASES.github.has(normalizeReflectProviderName(provider)) && env.GH_AW_LLM_PROVIDER_EXPLICIT !== "1") { + throw new Error("A copilot/ Claude model requires engine.model-provider: github when the model is selected dynamically; configure the provider and its credentials before running Claude"); + } + return match[1]; +} + +/** @param {string[]} args @param {string} provider @param {NodeJS.ProcessEnv} env @returns {string[]} */ +function normalizeClaudeModelArgs(args, provider, env) { + const normalized = [...args]; + for (let i = 0; i < normalized.length; i++) { + if (normalized[i] === "--") break; + if (normalized[i] === "--model" && i + 1 < normalized.length) { + normalized[++i] = normalizeClaudeModel(normalized[i], provider, env); + } else if (normalized[i].startsWith("--model=")) { + normalized[i] = `--model=${normalizeClaudeModel(normalized[i].slice("--model=".length), provider, env)}`; + } + } + return normalized; +} + /** @param {string[]} args @param {string} provider @param {{ env?: NodeJS.ProcessEnv, logger?: (message: string) => void }} [options] @returns {string[]} */ function normalizeCodexModelArgs(args, provider, options = {}) { const normalized = [...args]; @@ -93,4 +119,6 @@ module.exports = { injectModelFlagAfterExec, normalizeCodexModel, normalizeCodexModelArgs, + normalizeClaudeModel, + normalizeClaudeModelArgs, }; diff --git a/setup/js/parse_token_usage.cjs b/setup/js/parse_token_usage.cjs index bb030947..38a6d620 100644 --- a/setup/js/parse_token_usage.cjs +++ b/setup/js/parse_token_usage.cjs @@ -8,25 +8,35 @@ const { ERR_PARSE } = require("./error_codes.cjs"); const { parseTokenUsageJsonl, generateTokenUsageSummary, formatAICForOutput } = require("./parse_mcp_gateway_log.cjs"); const { calculateWorkingSetFromJSONL } = require("./working_set_metrics.cjs"); +const DEFAULT_GH_AW_DIR = "/tmp/gh-aw"; /** * Parses the firewall proxy token-usage.jsonl and appends a collapsible markdown * table to $GITHUB_STEP_SUMMARY via core.summary.addDetails. * - * Also writes aggregated token totals to /tmp/gh-aw/agent_usage.json so the data - * is bundled in the agent artifact and accessible to third-party tools. + * Also writes aggregated token totals to the gh-aw directory so the data is + * bundled in the agent artifact and accessible to third-party tools. */ -const TOKEN_USAGE_AUDIT_PATH = "/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl"; -const TOKEN_USAGE_PATH = "/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl"; +const TOKEN_USAGE_AUDIT_PATH = path.join(DEFAULT_GH_AW_DIR, "sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl"); +const TOKEN_USAGE_PATH = path.join(DEFAULT_GH_AW_DIR, "sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl"); // AWF v0.27.7+ may write token-usage.jsonl under --audit-dir as well as --proxy-logs-dir. // Include this path so the agent job captures token data regardless of which dir AWF chose. -const TOKEN_USAGE_AWF_AUDIT_PATH = "/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl"; +const TOKEN_USAGE_AWF_AUDIT_PATH = path.join(DEFAULT_GH_AW_DIR, "sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl"); const TOKEN_USAGE_PATHS = [TOKEN_USAGE_AUDIT_PATH, TOKEN_USAGE_AWF_AUDIT_PATH, TOKEN_USAGE_PATH]; -const AGENT_USAGE_PATH = "/tmp/gh-aw/agent_usage.json"; -const AGENT_USAGE_JSONL_PATH = "/tmp/gh-aw/agent_usage.jsonl"; -const COPILOT_SESSION_STATE_DIR = "/tmp/gh-aw/sandbox/agent/logs/copilot-session-state"; +const AGENT_USAGE_PATH = path.join(DEFAULT_GH_AW_DIR, "agent_usage.json"); +const AGENT_USAGE_JSONL_PATH = path.join(DEFAULT_GH_AW_DIR, "agent_usage.jsonl"); +const COPILOT_SESSION_STATE_DIR = path.join(DEFAULT_GH_AW_DIR, "sandbox/agent/logs/copilot-session-state"); const DEFAULT_SUMMARY_TITLE = "Token Usage"; +function getGhAwPath(relativePath) { + const root = process.env.GH_AW_TMP_DIR; + return path.join(root && root.trim() ? root.trim() : DEFAULT_GH_AW_DIR, relativePath); +} + +function getTokenUsagePaths() { + return [getGhAwPath("sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl"), getGhAwPath("sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl"), getGhAwPath("sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl")]; +} + function getUsageOutputPath(envName, defaultPath) { const configured = process.env[envName]; return configured && configured.trim() ? configured.trim() : defaultPath; @@ -34,8 +44,8 @@ function getUsageOutputPath(envName, defaultPath) { function writeEmptyUsageEvidence() { if (process.env.GH_AW_WRITE_EMPTY_USAGE !== "true") return; - const usagePath = getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", AGENT_USAGE_PATH); - const usageJSONLPath = getUsageOutputPath("GH_AW_AGENT_USAGE_JSONL_PATH", AGENT_USAGE_JSONL_PATH); + const usagePath = getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", getGhAwPath("agent_usage.json")); + const usageJSONLPath = getUsageOutputPath("GH_AW_AGENT_USAGE_JSONL_PATH", getGhAwPath("agent_usage.jsonl")); fs.mkdirSync(path.dirname(usagePath), { recursive: true }); fs.mkdirSync(path.dirname(usageJSONLPath), { recursive: true }); fs.writeFileSync(usagePath, '{"input_tokens":0,"output_tokens":0,"ai_credits":0}\n'); @@ -131,7 +141,7 @@ function getSummaryTitle() { * @param {string} sessionStateDir * @returns {{aiCredits: number, premiumRequests: number} | null} */ -function findCopilotUsageCheckpoint(sessionStateDir = COPILOT_SESSION_STATE_DIR) { +function findCopilotUsageCheckpoint(sessionStateDir = getGhAwPath("sandbox/agent/logs/copilot-session-state")) { if (!fs.existsSync(sessionStateDir)) return null; /** @type {string[]} */ @@ -196,8 +206,8 @@ async function reportCopilotUsageCheckpoint(checkpoint) { premium_requests: checkpoint.premiumRequests, }; try { - fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", AGENT_USAGE_PATH), JSON.stringify(agentUsage) + "\n"); - fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_JSONL_PATH", AGENT_USAGE_JSONL_PATH), JSON.stringify({ provider: "copilot", ai_credits: checkpoint.aiCredits, premium_requests: checkpoint.premiumRequests }) + "\n"); + fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", getGhAwPath("agent_usage.json")), JSON.stringify(agentUsage) + "\n"); + fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_JSONL_PATH", getGhAwPath("agent_usage.jsonl")), JSON.stringify({ provider: "copilot", ai_credits: checkpoint.aiCredits, premium_requests: checkpoint.premiumRequests }) + "\n"); } catch (error) { throw new Error(`${ERR_PARSE}: Failed to write Copilot usage files: ${getErrorMessage(error)}`, { cause: error }); } @@ -303,9 +313,9 @@ async function appendStepSummarySection(title, markdown, workingSet = null) { /** * Main function to parse token usage and write the step summary. */ -async function main(copilotSessionStateDir = COPILOT_SESSION_STATE_DIR) { +async function main(copilotSessionStateDir = getGhAwPath("sandbox/agent/logs/copilot-session-state")) { try { - const tokenUsagePaths = getReadableTokenUsagePaths(TOKEN_USAGE_PATHS); + const tokenUsagePaths = getReadableTokenUsagePaths(getTokenUsagePaths()); if (tokenUsagePaths.length === 0) { const checkpoint = findCopilotUsageCheckpoint(copilotSessionStateDir); if (checkpoint) { @@ -366,7 +376,7 @@ async function main(copilotSessionStateDir = COPILOT_SESSION_STATE_DIR) { ai_credits: summary.aiCreditsSource === "awf_reported" ? Number(summary.totalAIC.toFixed(6)) : Number((summary.totalAIC || 0).toFixed(3)), ...(primaryModel ? { primary_model: primaryModel } : {}), }; - fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", AGENT_USAGE_PATH), JSON.stringify(agentUsage) + "\n"); + fs.writeFileSync(getUsageOutputPath("GH_AW_AGENT_USAGE_PATH", getGhAwPath("agent_usage.json")), JSON.stringify(agentUsage) + "\n"); if (primaryModel) { core.exportVariable("GH_AW_PRIMARY_MODEL", primaryModel); @@ -414,6 +424,7 @@ if (typeof module !== "undefined" && module.exports) { findCopilotUsageCheckpoint, reportCopilotUsageCheckpoint, getUsageOutputPath, + getTokenUsagePaths, writeEmptyUsageEvidence, }; } diff --git a/setup/js/types/unified_session.d.ts b/setup/js/types/unified_session.d.ts index 1737b8ca..dc1c780a 100644 --- a/setup/js/types/unified_session.d.ts +++ b/setup/js/types/unified_session.d.ts @@ -193,6 +193,7 @@ export interface WorkflowInfoData { mcpgVersion?: JsonValue; requestedModel?: JsonValue; triggerType?: JsonValue; + dryRun?: JsonValue; workflow?: JsonValue; repository?: JsonValue; runId?: JsonValue; diff --git a/setup/js/unified_session_payload.cjs b/setup/js/unified_session_payload.cjs index 725b00b7..2466ee20 100644 --- a/setup/js/unified_session_payload.cjs +++ b/setup/js/unified_session_payload.cjs @@ -200,6 +200,7 @@ const EVENT_FIELDS = { threshold: ["threshold"], }, "workflow.info": { + dryRun: ["dryRun", "dry_run"], engineId: ["engineId", "engine_id", "engine"], agentVersion: ["agentVersion", "agent_version"], cliVersion: ["cliVersion", "cli_version"], diff --git a/setup/session_parsers.go b/setup/session_parsers.go index ee954155..e072be78 100644 --- a/setup/session_parsers.go +++ b/setup/session_parsers.go @@ -11,6 +11,7 @@ import "embed" //go:embed js/agent_session.cjs js/agent_session_render.cjs js/claude_session.cjs //go:embed js/provider_refusal.cjs //go:embed js/codex_session.cjs js/codex_log_framing.cjs js/copilot_session.cjs +//go:embed js/copilot_workflow_events.cjs js/dynamic_workflow_session.cjs //go:embed js/gemini_session.cjs //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 diff --git a/setup/sh/append_agent_step_summary.sh b/setup/sh/append_agent_step_summary.sh index e5727359..7e3940ee 100644 --- a/setup/sh/append_agent_step_summary.sh +++ b/setup/sh/append_agent_step_summary.sh @@ -4,6 +4,7 @@ set +o histexpand # Append the agent's step summary to the real $GITHUB_STEP_SUMMARY. # The file was written by the agent and already redacted for secrets. # This is a no-op when the file is empty (agent wrote nothing). -if [ -s /tmp/gh-aw/agent-step-summary.md ]; then - cat /tmp/gh-aw/agent-step-summary.md >> "$GITHUB_STEP_SUMMARY" +gh_aw_dir="${GH_AW_TMP_DIR:-/tmp/gh-aw}" +if [ -s "$gh_aw_dir/agent-step-summary.md" ]; then + cat "$gh_aw_dir/agent-step-summary.md" >> "$GITHUB_STEP_SUMMARY" fi