diff --git a/setup/js/Dockerfile.safe-outputs-mcp b/setup/js/Dockerfile.safe-outputs-mcp index 5b349cb..5818e8b 100644 --- a/setup/js/Dockerfile.safe-outputs-mcp +++ b/setup/js/Dockerfile.safe-outputs-mcp @@ -10,13 +10,19 @@ ARG DOCKERFILE_HASH="" ARG NPM_VERSION=11.19.0 # Intentional: upgrade all packages to pick up security fixes; downstream digest pins the result. -# After upgrading npm, patch its bundled dependencies to meet minimum safe versions -# (tar >= 7.5.21 for CVE-2025-tar, brace-expansion >= 5.0.8 for CVE-2025-brace-expansion). +# After upgrading npm, patch its bundled dependencies to meet minimum safe versions. +# Install into a temp prefix (avoids npm's own private package.json) then overlay into npm's +# bundled node_modules: brace-expansion >= 5.0.8 (GHSA-mh99-v99m-4gvg), tar >= 7.5.22. RUN apk upgrade --no-cache \ && apk add --no-cache git \ && apk info -v | sort \ && npm install --global "npm@${NPM_VERSION}" \ - && npm install --prefix "$(npm root -g)/npm" --no-save "tar@^7.5.22" "brace-expansion@^5.0.8" \ + && tmpdir=$(mktemp -d) \ + && npm --prefix "$tmpdir" install --no-save "tar@^7.5.22" "brace-expansion@^5.0.8" \ + && npm_modules="$(npm root -g)/npm/node_modules" \ + && cp -rf "$tmpdir/node_modules/brace-expansion/." "$npm_modules/brace-expansion/" \ + && cp -rf "$tmpdir/node_modules/tar/." "$npm_modules/tar/" \ + && rm -rf "$tmpdir" \ && npm cache clean --force LABEL org.opencontainers.image.source="https://github.com/github/gh-aw" \ diff --git a/setup/js/apply_safe_outputs_replay.cjs b/setup/js/apply_safe_outputs_replay.cjs index 6882074..71e3cd7 100644 --- a/setup/js/apply_safe_outputs_replay.cjs +++ b/setup/js/apply_safe_outputs_replay.cjs @@ -73,7 +73,7 @@ async function downloadAgentArtifact(runId, destDir, repoSlug) { try { fs.mkdirSync(destDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${destDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${destDir}: ${getErrorMessage(err)}`, { cause: err }); } const args = ["run", "download", runId, "--name", "agent", "--dir", destDir]; @@ -107,7 +107,7 @@ function buildHandlerConfigFromOutput(agentOutputFile) { try { content = fs.readFileSync(agentOutputFile, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${agentOutputFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${agentOutputFile}: ${getErrorMessage(err)}`, { cause: err }); } let validatedOutput; try { diff --git a/setup/js/apply_samples.cjs b/setup/js/apply_samples.cjs index 8481fc2..c102c58 100644 --- a/setup/js/apply_samples.cjs +++ b/setup/js/apply_samples.cjs @@ -41,6 +41,7 @@ const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const DEFAULT_BASE_BRANCH = process.env.GH_AW_CUSTOM_BASE_BRANCH || process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME || "main"; const PATCH_SIDECAR_TOOLS = new Set(["create_pull_request", "push_to_pull_request_branch"]); +const FETCH_TIMEOUT_MS = 120_000; /** * @typedef {Object} SampleEntry @@ -188,7 +189,7 @@ async function fetchPullRequestHeadRef({ owner, repo, pullNumber }) { const token = selectTokenForRepo(owner, repo); if (token) headers["Authorization"] = `Bearer ${token}`; try { - const resp = await fetch(url, { headers }); + const resp = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!resp.ok) { core.warning(`apply_samples: GET ${url} returned HTTP ${resp.status}`); return null; @@ -458,7 +459,7 @@ async function preStagePatch(entry, index, workspace) { try { fs.writeFileSync(tmpPatch, patch.endsWith("\n") ? patch : patch + "\n"); } catch (err) { - throw new Error(`Failed to write file ${tmpPatch}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${tmpPatch}: ${getErrorMessage(err)}`, { cause: err }); } try { runGit(["apply", "--whitespace=nowarn", tmpPatch], repoCwd); @@ -711,7 +712,7 @@ async function main() { if (require.main === module) { main().catch(err => { - core.setFailed(err && err.stack ? err.stack : String(err)); + core.setFailed(err && err.stack ? err.stack : getErrorMessage(err)); }); } diff --git a/setup/js/artifact_client.cjs b/setup/js/artifact_client.cjs index 50a3bd9..8ac97f1 100644 --- a/setup/js/artifact_client.cjs +++ b/setup/js/artifact_client.cjs @@ -23,6 +23,8 @@ const RESULTS_SCOPE_PREFIX = "Actions.Results:"; const TWIRP_ARTIFACT_SERVICE = "github.actions.results.api.v1.ArtifactService"; const MAX_ARTIFACTS = 1000; const PAGE_SIZE = 100; +const FETCH_TIMEOUT_MS = 120_000; +const FETCH_TRANSFER_TIMEOUT_MS = 300_000; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -99,6 +101,7 @@ async function twirpRequest(method, body) { "Content-Type": "application/json", }, body: JSON.stringify(body), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (response.ok) { @@ -214,16 +217,22 @@ async function uploadFileToSignedURL(filePath, signedUploadURL, contentType) { } catch (err) { throw new Error(`Failed to read file metadata for ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } - const response = await fetch(signedUploadURL, { - method: "PUT", - headers: { - "Content-Type": contentType, - "Content-Length": String(stats.size), - "x-ms-blob-type": "BlockBlob", - }, - body: fs.createReadStream(filePath), - duplex: "half", - }); + let response; + try { + response = await fetch(signedUploadURL, { + method: "PUT", + headers: { + "Content-Type": contentType, + "Content-Length": String(stats.size), + "x-ms-blob-type": "BlockBlob", + }, + body: fs.createReadStream(filePath), + duplex: "half", + signal: AbortSignal.timeout(FETCH_TRANSFER_TIMEOUT_MS), + }); + } catch (err) { + throw new Error(`artifact blob upload failed: ${getErrorMessage(err)}`, { cause: err }); + } if (!response.ok) { const body = await response.text(); throw new Error(`artifact blob upload failed (${response.status}): ${body || response.statusText}`); @@ -266,13 +275,19 @@ class DefaultArtifactClient { const url = parseURL(`/repos/${findBy.repositoryOwner}/${findBy.repositoryName}/actions/runs/${findBy.workflowRunId}/artifacts`, serverUrl, `Failed to construct artifacts URL for run ${findBy.workflowRunId}`); url.searchParams.set("per_page", String(PAGE_SIZE)); url.searchParams.set("page", String(page)); - const response = await fetch(url.toString(), { - headers: { - Authorization: "Bearer " + findBy.token, - Accept: "application/vnd.github+json", - "User-Agent": "gh-aw-artifact-client", - }, - }); + let response; + try { + response = await fetch(url.toString(), { + headers: { + Authorization: "Bearer " + findBy.token, + Accept: "application/vnd.github+json", + "User-Agent": "gh-aw-artifact-client", + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch (err) { + throw new Error(`failed to list artifacts: ${getErrorMessage(err)}`, { cause: err }); + } if (!response.ok) { throw new Error(`failed to list artifacts (${response.status}): ${await response.text()}`); } @@ -308,7 +323,7 @@ class DefaultArtifactClient { try { fs.mkdirSync(destination, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${destination}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${destination}: ${getErrorMessage(err)}`, { cause: err }); } const apiUrl = parseURL( @@ -316,14 +331,20 @@ class DefaultArtifactClient { process.env.GITHUB_API_URL || "https://api.github.com", `Failed to construct download URL for artifact ${artifactId}` ); - const redirectResponse = await fetch(apiUrl.toString(), { - headers: { - Authorization: "Bearer " + findBy.token, - Accept: "application/vnd.github+json", - "User-Agent": "gh-aw-artifact-client", - }, - redirect: "manual", - }); + let redirectResponse; + try { + redirectResponse = await fetch(apiUrl.toString(), { + headers: { + Authorization: "Bearer " + findBy.token, + Accept: "application/vnd.github+json", + "User-Agent": "gh-aw-artifact-client", + }, + redirect: "manual", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch (err) { + throw new Error(`unable to download artifact: ${getErrorMessage(err)}`, { cause: err }); + } if (![301, 302, 303, 307, 308].includes(redirectResponse.status)) { throw new Error(`unable to download artifact: unexpected status ${redirectResponse.status}`); } @@ -332,7 +353,12 @@ class DefaultArtifactClient { throw new Error("unable to download artifact: missing redirect location"); } - const blobResponse = await fetch(location); + let blobResponse; + try { + blobResponse = await fetch(location, { signal: AbortSignal.timeout(FETCH_TRANSFER_TIMEOUT_MS) }); + } catch (err) { + throw new Error(`artifact blob download failed: ${getErrorMessage(err)}`, { cause: err }); + } if (!blobResponse.ok) { throw new Error(`artifact blob download failed (${blobResponse.status})`); } diff --git a/setup/js/build_checkout_manifest.cjs b/setup/js/build_checkout_manifest.cjs index dd7c26b..5391354 100644 --- a/setup/js/build_checkout_manifest.cjs +++ b/setup/js/build_checkout_manifest.cjs @@ -113,7 +113,7 @@ function buildCheckoutManifest(entries, options = {}) { try { fs.mkdirSync(manifestDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${manifestDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${manifestDir}: ${getErrorMessage(err)}`, { cause: err }); } const manifestPath = path.join(manifestDir, "checkout-manifest.json"); const manifest = {}; @@ -147,7 +147,7 @@ function buildCheckoutManifest(entries, options = {}) { try { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); } catch (err) { - throw new Error(`Failed to write file ${manifestPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${manifestPath}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`checkout-manifest written to ${manifestPath}`); return { manifestPath, manifest }; diff --git a/setup/js/check_daily_aic_workflow_guardrail.cjs b/setup/js/check_daily_aic_workflow_guardrail.cjs index 6ad7ed0..6b3529a 100644 --- a/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -703,12 +703,16 @@ async function main() { } catch (summaryError) { core.warning(`Failed to write daily AIC summary: ${getErrorMessage(summaryError)}`); } - core.warning(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`); - core.setFailed(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`); + // Log as info so the activation job succeeds. The daily_ai_credits_exceeded output + // is already set to "true"; the agent job's condition (daily_ai_credits_exceeded != 'true') + // will skip the agent, and the conclusion job will handle reporting via the + // daily_ai_credits_exceeded flag. Failing the activation job here causes the overall + // workflow to fail even though hitting the daily limit is an expected, graceful outcome. + core.info(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`); } catch (error) { // Treat unexpected guardrail execution errors as non-blocking skips so transient // API/runtime issues do not fail activation. The output stays at the default "false", - // allowing the agent to run. Legitimate threshold exceedance still fails via setFailed. + // allowing the agent to run. core.warning(`Daily workflow AI Credits guardrail encountered an unexpected error and will be skipped: ${getErrorMessage(error)}`); } } diff --git a/setup/js/check_version_updates.cjs b/setup/js/check_version_updates.cjs index 06ccfa3..4b848a0 100644 --- a/setup/js/check_version_updates.cjs +++ b/setup/js/check_version_updates.cjs @@ -20,6 +20,7 @@ const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const CONFIG_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; +const FETCH_TIMEOUT_MS = 120_000; /** * Parse an official version string (must be in vMAJOR.MINOR.PATCH format). @@ -89,7 +90,7 @@ async function main() { try { config = await withRetry( async () => { - const res = await fetch(CONFIG_URL); + const res = await fetch(CONFIG_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) { const err = new Error(`HTTP ${res.status} fetching ${CONFIG_URL}`); // @ts-ignore - Attach status so the retry predicate can inspect it diff --git a/setup/js/check_workflow_recompile_needed.cjs b/setup/js/check_workflow_recompile_needed.cjs index 580c5b2..9653b48 100644 --- a/setup/js/check_workflow_recompile_needed.cjs +++ b/setup/js/check_workflow_recompile_needed.cjs @@ -111,7 +111,7 @@ async function filterFilesNeedingUpdate(comparisonRef, changedFiles, workspaceDi try { workingTreeContent = fs.readFileSync(workingTreePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${workingTreePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${workingTreePath}: ${getErrorMessage(err)}`, { cause: err }); } const { stdout, exitCode } = await exec.getExecOutput("git", ["show", `${comparisonRef}:${file}`], { ignoreReturnCode: true, diff --git a/setup/js/checkout_pr_branch.cjs b/setup/js/checkout_pr_branch.cjs index a28e996..f930283 100644 --- a/setup/js/checkout_pr_branch.cjs +++ b/setup/js/checkout_pr_branch.cjs @@ -19,6 +19,11 @@ * - Also run in base repository context * - Uses refs/pull/N/head to fetch PR branch * + * 4. workflow_dispatch with aw_context: + * - When aw_context input contains item_type=="pull_request" and item_number, + * the PR number is extracted and the head is fetched via refs/pull/N/head + * - Mirrors the guard in the compiled workflow's if: condition + * * NOTE: This handler operates within the PR context from the workflow event * and does not support cross-repository operations or target-repo parameters. * No allowlist validation (checkAllowedRepo/validateTargetRepo) is needed as @@ -192,6 +197,39 @@ async function main() { core.info(`Detected ${eventName} event on PR #${pullRequest.number}, will fetch PR ref`); } + // Handle workflow_dispatch events with aw_context pointing to a PR + if (!pullRequest && eventName === "workflow_dispatch") { + const awContextStr = context.payload.inputs?.aw_context; + if (awContextStr) { + try { + const awContext = JSON.parse(awContextStr); + const prNumber = Number(awContext.item_number); + if (awContext.item_type === "pull_request" && Number.isInteger(prNumber) && prNumber > 0) { + if (awContext.repo) { + const currentRepo = `${context.repo.owner}/${context.repo.repo}`; + if (awContext.repo !== currentRepo) { + core.warning(`Cross-repository workflow_dispatch is not supported: aw_context.repo (${awContext.repo}) does not match current repository (${currentRepo}), skipping checkout`); + } else { + pullRequest = { + number: prNumber, + state: "open", + }; + core.info(`Detected workflow_dispatch event for PR #${pullRequest.number} via aw_context, will fetch PR ref`); + } + } else { + pullRequest = { + number: prNumber, + state: "open", + }; + core.info(`Detected workflow_dispatch event for PR #${pullRequest.number} via aw_context, will fetch PR ref`); + } + } + } catch (e) { + core.warning(`Failed to parse aw_context: ${getErrorMessage(e)}`); + } + } + } + if (!pullRequest) { core.info("No pull request context available, skipping checkout"); core.setOutput("checkout_pr_success", "true"); diff --git a/setup/js/claude_harness.cjs b/setup/js/claude_harness.cjs index 33be219..66fc378 100644 --- a/setup/js/claude_harness.cjs +++ b/setup/js/claude_harness.cjs @@ -54,6 +54,7 @@ const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractD const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError } = require("./harness_retry_guard.cjs"); const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); const { applyModelFallback } = require("./model_fallback.cjs"); +const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); // Pattern to detect Anthropic API overload errors (HTTP 529). // Matches "overloaded_error" from the Anthropic error type field, and the @@ -479,12 +480,26 @@ async function main() { } const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.maxRunsExceeded) { + const trustedAICreditsExceeded = nonRetryableGuard.aiCreditsExceeded && parseMaxAICreditsExceededFromAuditLog(); + if (nonRetryableGuard.aiCreditsExceeded && !trustedAICreditsExceeded) { + log(`attempt ${attempt + 1}: AI credits marker found in CLI output without trusted firewall audit confirmation — preserving normal failure handling`); + } + const shouldTreatAICreditsExceededAsSuccess = trustedAICreditsExceeded && !isAuthenticationFailed; + if (shouldTreatAICreditsExceededAsSuccess || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.maxRunsExceeded) { const reasons = []; - if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded"); + if (shouldTreatAICreditsExceededAsSuccess) reasons.push("AI credits budget exceeded"); if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests"); if (nonRetryableGuard.maxRunsExceeded) reasons.push("maximum LLM invocations exceeded"); log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); + // When the per-run AI credits budget is exceeded the AWF firewall intentionally + // stopped the agent — this is controlled budget enforcement, not an unexpected + // error. Exit 0 so the agent step and job succeed; the ai_credits_rate_limit_error + // output surfaced by parse-mcp-gateway will inform downstream handlers (e.g. + // handle_agent_failure) of the budget exceedance. + if (shouldTreatAICreditsExceededAsSuccess) { + log(`attempt ${attempt + 1}: AI credits budget enforced — exiting 0 (budget control, not an error)`); + lastExitCode = 0; + } break; } diff --git a/setup/js/codex_harness.cjs b/setup/js/codex_harness.cjs index 11aa5fb..bfe086c 100644 --- a/setup/js/codex_harness.cjs +++ b/setup/js/codex_harness.cjs @@ -39,7 +39,6 @@ const { runProcess, formatDuration, sleep, MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, const { AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, - AWF_REFLECT_TIMEOUT_MS, AWF_MODELS_URL_TIMEOUT_MS, GEMINI_MODEL_NAME_PREFIX, enrichReflectModels, @@ -55,6 +54,7 @@ const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSi const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); const { resolveRetryConfig } = require("./harness_retry_config.cjs"); const { applyModelFallback, injectModelFlagAfterExec } = require("./model_fallback.cjs"); +const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); // Pattern to detect OpenAI rate-limit errors. // Matches the JSON error type field ("rate_limit_exceeded"), the HTTP status code @@ -535,7 +535,10 @@ async function main() { // Fetch AWF API proxy reflection data before running the agent to capture initial proxy state. // This is best-effort: failures are logged but do not affect the agent run. - await fetchAWFReflect({ logger: log }); + // Skip when AWF_REFLECT_ENABLED is not "1" (e.g. no api-proxy running in sandbox or test mode). + if (process.env.AWF_REFLECT_ENABLED === "1") { + await fetchAWFReflect({ logger: log }); + } const codexHome = process.env.CODEX_HOME || ""; let codexEnv = codexChildEnv; const providerConfig = configureCodexProviderFromReflect({ @@ -661,13 +664,27 @@ async function main() { } const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive || nonRetryableGuard.maxRunsExceeded) { + const trustedAICreditsExceeded = nonRetryableGuard.aiCreditsExceeded && parseMaxAICreditsExceededFromAuditLog(); + if (nonRetryableGuard.aiCreditsExceeded && !trustedAICreditsExceeded) { + log(`attempt ${attempt + 1}: AI credits marker found in CLI output without trusted firewall audit confirmation — preserving normal failure handling`); + } + const shouldTreatAICreditsExceededAsSuccess = trustedAICreditsExceeded && !isAuthenticationFailed && !isMissingApiKey; + if (shouldTreatAICreditsExceededAsSuccess || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive || nonRetryableGuard.maxRunsExceeded) { const reasons = []; - if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded"); + if (shouldTreatAICreditsExceededAsSuccess) reasons.push("AI credits budget exceeded"); if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests"); if (nonRetryableGuard.goalAlreadyActive) reasons.push("goal is already active for this thread (use update_goal when the current goal is complete)"); if (nonRetryableGuard.maxRunsExceeded) reasons.push("maximum LLM invocations exceeded"); log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); + // When the per-run AI credits budget is exceeded the AWF firewall intentionally + // stopped the agent — this is controlled budget enforcement, not an unexpected + // error. Exit 0 so the agent step and job succeed; the ai_credits_rate_limit_error + // output surfaced by parse-mcp-gateway will inform downstream handlers (e.g. + // handle_agent_failure) of the budget exceedance. + if (shouldTreatAICreditsExceededAsSuccess) { + log(`attempt ${attempt + 1}: AI credits budget enforced — exiting 0 (budget control, not an error)`); + lastExitCode = 0; + } break; } @@ -737,7 +754,10 @@ async function main() { } // Fetch AWF API proxy reflection data and persist to disk for post-run step summary. - await fetchAWFReflect({ logger: log }); + // Skip when AWF_REFLECT_ENABLED is not "1" (e.g. no api-proxy running in sandbox or test mode). + if (process.env.AWF_REFLECT_ENABLED === "1") { + await fetchAWFReflect({ logger: log }); + } log(`done: exitCode=${lastExitCode} totalDuration=${formatDuration(Date.now() - driverStartTime)}`); process.exit(lastExitCode); diff --git a/setup/js/convert_gateway_config_shared.cjs b/setup/js/convert_gateway_config_shared.cjs index 6229c5c..ac5b7c8 100644 --- a/setup/js/convert_gateway_config_shared.cjs +++ b/setup/js/convert_gateway_config_shared.cjs @@ -158,7 +158,7 @@ function writeSecureOutput(outputPath, output) { fs.writeFileSync(outputPath, output, { mode: 0o600 }); fs.chmodSync(outputPath, 0o600); } catch (err) { - throw new Error(`Failed to write file ${outputPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${outputPath}: ${getErrorMessage(err)}`, { cause: err }); } } diff --git a/setup/js/copilot_harness.cjs b/setup/js/copilot_harness.cjs index 100557e..688471f 100644 --- a/setup/js/copilot_harness.cjs +++ b/setup/js/copilot_harness.cjs @@ -77,6 +77,7 @@ const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs"); const { applyModelFallback } = require("./model_fallback.cjs"); const { loadModelsJson } = require("./model_costs.cjs"); const { resolveConfiguredCopilotModel } = require("./resolve_model_alias.cjs"); +const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); const AWF_CONFIG_PATH = process.env.GH_AW_AWF_CONFIG_PATH || "/tmp/gh-aw/awf-config.json"; @@ -1271,21 +1272,36 @@ async function main() { // only armed after hasTerminalSafeOutput is true, so watchdogFired on a no-stdio-output // run means the agent completed its task (wrote safe-output) but produced no console // output before the watchdog terminated the idle process. - if ((failureClass === "partial_execution" || failureClass === "long_run_exit" || (failureClass === "no_output" && result.watchdogFired)) && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { + const isExpectedLateExit = failureClass === "partial_execution" || failureClass === "long_run_exit" || (failureClass === "no_output" && result.watchdogFired) || (failureClass === "authentication_failed" && result.watchdogFired); + if (isExpectedLateExit && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { const reason = result.watchdogFired ? "post-result watchdog fired after terminal safe-output was emitted" : "partial execution after terminal safe-output was already produced"; log(`attempt ${attempt + 1}: ${reason} — treating as success (late-activity exit suppressed)`); lastExitCode = 0; break; } - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || isInvocationCapExceeded) { + const trustedAICreditsExceeded = nonRetryableGuard.aiCreditsExceeded && parseMaxAICreditsExceededFromAuditLog(); + if (nonRetryableGuard.aiCreditsExceeded && !trustedAICreditsExceeded) { + log(`attempt ${attempt + 1}: AI credits marker found in CLI output without trusted firewall audit confirmation — preserving normal failure handling`); + } + const shouldTreatAICreditsExceededAsSuccess = trustedAICreditsExceeded && !isAuthenticationFailed; + if (shouldTreatAICreditsExceededAsSuccess || nonRetryableGuard.awfAPIProxyBlockingRequests || isInvocationCapExceeded) { const reasons = []; - if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded"); + if (shouldTreatAICreditsExceededAsSuccess) reasons.push("AI credits budget exceeded"); if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests"); if (isInvocationCapExceeded) { reasons.push("LLM invocation cap saturated — the pooled per-run budget is fully exhausted; retries cannot make progress"); } log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); + // When the per-run AI credits budget is exceeded the AWF firewall intentionally + // stopped the agent — this is controlled budget enforcement, not an unexpected + // error. Exit 0 so the agent step and job succeed; the ai_credits_rate_limit_error + // output surfaced by parse-mcp-gateway will inform downstream handlers (e.g. + // handle_agent_failure) of the budget exceedance. + if (shouldTreatAICreditsExceededAsSuccess) { + log(`attempt ${attempt + 1}: AI credits budget enforced — exiting 0 (budget control, not an error)`); + lastExitCode = 0; + } break; } diff --git a/setup/js/copilot_sdk_permissions.cjs b/setup/js/copilot_sdk_permissions.cjs index 68e41ff..a8dd6f9 100644 --- a/setup/js/copilot_sdk_permissions.cjs +++ b/setup/js/copilot_sdk_permissions.cjs @@ -352,6 +352,21 @@ function buildCopilotSDKPermissionHandler(permissionConfig, approveAll, logOptio return allowedToolEntries.has("write"); case "read": // Any read grant (read, read(...), read:*) is path-agnostic in Copilot SDK. + // Always allow reads for paths at or under the workspace root (GITHUB_WORKSPACE). + // Every workflow runs inside its own checkout and must be able to read its source tree + // regardless of any narrower tool-permission scoping configured elsewhere. + // + // Use path.resolve + path.relative for containment rather than a string-prefix check so + // that ".." traversal paths (e.g. workspace/../../../../etc/passwd) are rejected and + // relative paths (e.g. "AGENTS.md") are correctly resolved inside the workspace. + if (logOptions?.workspaceRoot && typeof request.path === "string" && request.path.length > 0) { + const resolvedWorkspace = path.resolve(logOptions.workspaceRoot); + const resolvedPath = path.isAbsolute(request.path) ? path.resolve(request.path) : path.resolve(resolvedWorkspace, request.path); + const rel = path.relative(resolvedWorkspace, resolvedPath); + if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) { + return true; + } + } return hasReadGrant || allowedToolEntries.has("shell") || isReadPathAllowedByShellRules(request.path, readablePathPatterns, logOptions?.workspaceRoot); case "url": return allowedToolEntries.has("web_fetch"); diff --git a/setup/js/create_code_scanning_alert.cjs b/setup/js/create_code_scanning_alert.cjs index 2ed6f46..10fdbc3 100644 --- a/setup/js/create_code_scanning_alert.cjs +++ b/setup/js/create_code_scanning_alert.cjs @@ -90,7 +90,7 @@ async function main(config = {}) { try { fs.writeFileSync(sarifFilePath, JSON.stringify(sarifContent, null, 2)); } catch (err) { - throw new Error(`${ERR_SYSTEM}: Failed to write file ${sarifFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`${ERR_SYSTEM}: Failed to write file ${sarifFilePath}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`✓ Updated SARIF file with ${validFindings.length} finding(s): ${sarifFilePath}`); } diff --git a/setup/js/create_pull_request.cjs b/setup/js/create_pull_request.cjs index 10546c6..f76cbf7 100644 --- a/setup/js/create_pull_request.cjs +++ b/setup/js/create_pull_request.cjs @@ -416,7 +416,10 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile } core.warning(`Rewriting bundled commits to a single linear commit for signed push compatibility (base: ${baseRef})`); - const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi, { excludedFiles: options.excludedFiles }); + const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi, { + excludedFiles: options.excludedFiles, + rebaseOnto: fallbackBaseRef, + }); core.info(`Bundle rewrite completed (new HEAD: ${newHead})`); } @@ -710,7 +713,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio core.warning(`Remote branch "${branchName}" cannot be deleted due to branch protection rules (recreate-ref blocked). ` + `Falling back to rename with random suffix.`); deleteBlocked = true; } else { - throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with recreate-ref: ${message || String(err)}`, { cause: err }); + throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with recreate-ref: ${message || getErrorMessage(err)}`, { cause: err }); } } if (!deleteBlocked) { @@ -1211,7 +1214,7 @@ async function main(config = {}) { try { patchContent = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } isEmpty = !patchContent || !patchContent.trim(); } @@ -1414,7 +1417,7 @@ async function main(config = {}) { try { patchStats = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } if (patchStats.trim()) { summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; @@ -1928,7 +1931,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead try { fs.writeFileSync(patchFilePath, patchContent, "utf8"); } catch (err) { - throw new Error(`Failed to write file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } } } @@ -2172,7 +2175,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead try { patchContent = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } patchPreview = generatePatchPreview(patchContent); } @@ -2695,7 +2698,7 @@ ${patchPreview}`; try { patchContent = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } patchPreview = generatePatchPreview(patchContent); } @@ -2760,7 +2763,7 @@ ${patchPreview}`; try { patchContent = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } patchPreview = generatePatchPreview(patchContent); } diff --git a/setup/js/detect_agent_errors.cjs b/setup/js/detect_agent_errors.cjs index 4c1966f..9c99c14 100644 --- a/setup/js/detect_agent_errors.cjs +++ b/setup/js/detect_agent_errors.cjs @@ -63,8 +63,49 @@ const MCP_POLICY_BLOCKED_PATTERN = /MCP servers were blocked by policy:/; // [sdk-driver] error: Timeout after 870000ms waiting for session.idle // The second form can occur even when the driver collected output, and should // still be classified as a timeout for conclusion/reporting purposes. +// NOTE: use isAgenticEngineTimeout() for detection logic that excludes post-result +// watchdog SIGTERMs (watchdogFired=true). This pattern is exported for direct tests only. const AGENTIC_ENGINE_TIMEOUT_PATTERN = /(?:signal=SIG(?:TERM|KILL|INT)|Timeout after \d+ms waiting for session\.idle)/; +// Pattern: copilot-harness "process closed" line with SIGTERM and watchdogFired=true. +// This indicates the post-result idle watchdog fired a SIGTERM — the agent completed +// its work but the process did not exit cleanly in time. This is NOT a step timeout. +const WATCHDOG_SIGTERM_PATTERN = /process closed[^\n]*signal=SIG(?:TERM|KILL|INT)[^\n]*watchdogFired=true/; + +// Pattern: copilot-harness "process closed" line with SIGTERM and watchdogFired NOT true +// (watchdogFired=false or watchdogFired field absent). This indicates a genuine external kill, +// typically from the step timeout-minutes limit. +const STEP_TIMEOUT_SIGTERM_PATTERN = /process closed[^\n]*signal=SIG(?:TERM|KILL|INT)(?![^\n]*watchdogFired=true)/; +const PROCESS_CLOSED_SIGTERM_PATTERN = /process closed[^\n]*signal=SIG(?:TERM|KILL|INT)/; + +/** + * Determines if the log content shows a genuine agentic engine timeout. + * + * Returns false when the only SIGTERM source is the post-result idle watchdog + * (watchdogFired=true on the "process closed" line). The watchdog fires when the + * process is idle after completing its work, which is NOT a step timeout. + * + * @param {string} logContent - Contents of the agent stdio log + * @returns {boolean} + */ +function isAgenticEngineTimeout(logContent) { + // Always detect SDK idle-timeout (distinct from the step timeout). + if (/Timeout after \d+ms waiting for session\.idle/.test(logContent)) return true; + + // No signal-based termination at all. + if (!AGENTIC_ENGINE_TIMEOUT_PATTERN.test(logContent)) return false; + + // If there is a "process closed" line with SIGTERM and watchdogFired=true, the post-result + // watchdog fired. Check whether there is also a "process closed" SIGTERM line that did NOT + // have watchdogFired=true (which would mean a genuine external kill happened too). + if (WATCHDOG_SIGTERM_PATTERN.test(logContent)) { + return STEP_TIMEOUT_SIGTERM_PATTERN.test(logContent); + } + + // Only classify as timeout when the signal is on a "process closed" line. + return PROCESS_CLOSED_SIGTERM_PATTERN.test(logContent); +} + // Pattern: Configured model is invalid or unavailable. // Covers common engine/provider variants: // - "The requested model is not supported" @@ -193,7 +234,7 @@ function detectErrors(logContent) { return { inferenceAccessError: INFERENCE_ACCESS_ERROR_PATTERN.test(logContent), mcpPolicyError: MCP_POLICY_BLOCKED_PATTERN.test(logContent), - agenticEngineTimeout: AGENTIC_ENGINE_TIMEOUT_PATTERN.test(logContent), + agenticEngineTimeout: isAgenticEngineTimeout(logContent), modelNotSupportedError: MODEL_NOT_SUPPORTED_PATTERN.test(logContent), http400ResponseError: HTTP_400_RESPONSE_ERROR_PATTERN.test(logContent), capiQuotaExceededError: isCAPIQuotaExceededError(logContent), @@ -324,9 +365,13 @@ module.exports = { isCAPIQuotaExceededError, isInvocationCapExceededError, isMaxCacheMissesExceededError, + isAgenticEngineTimeout, INFERENCE_ACCESS_ERROR_PATTERN, MCP_POLICY_BLOCKED_PATTERN, AGENTIC_ENGINE_TIMEOUT_PATTERN, + WATCHDOG_SIGTERM_PATTERN, + STEP_TIMEOUT_SIGTERM_PATTERN, + PROCESS_CLOSED_SIGTERM_PATTERN, MODEL_NOT_SUPPORTED_PATTERN, HTTP_400_RESPONSE_ERROR_PATTERN, CAPI_QUOTA_EXCEEDED_PATTERN, diff --git a/setup/js/dismiss_pull_request_review.cjs b/setup/js/dismiss_pull_request_review.cjs index d5a65bf..1a72bf5 100644 --- a/setup/js/dismiss_pull_request_review.cjs +++ b/setup/js/dismiss_pull_request_review.cjs @@ -225,14 +225,34 @@ async function main(config = {}) { }; } - const { data: review } = await githubClient.rest.pulls.getReview({ - owner, - repo, - pull_number: pullRequestNumber, - review_id: reviewId, - }); + let review; + try { + const { data } = await githubClient.rest.pulls.getReview({ + owner, + repo, + pull_number: pullRequestNumber, + review_id: reviewId, + }); + review = data; + } catch (getReviewError) { + if (getReviewError?.status === 404) { + return { + success: true, + skipped: true, + reason: "review no longer exists", + review_id: reviewId, + pull_request_number: pullRequestNumber, + repo: `${owner}/${repo}`, + }; + } + if (getReviewError && typeof getReviewError.message === "string") { + getReviewError.message = `Failed to fetch review ${reviewId} on ${owner}/${repo}#${pullRequestNumber}: ` + getReviewError.message; + } + throw getReviewError; + } const reviewAuthorLogin = review?.user?.login; + const reviewAuthorType = typeof review?.user?.type === "string" ? review.user.type.trim() : ""; if (typeof reviewAuthorLogin !== "string" || reviewAuthorLogin.trim() === "") { return { success: false, @@ -241,6 +261,18 @@ async function main(config = {}) { } const reviewAuthor = reviewAuthorLogin.trim(); if (reviewAuthor !== expectedAuthor) { + if (reviewAuthorType === "Bot") { + const warningMessage = + `Skipping dismiss_pull_request_review for review ${reviewId}: ` + + `review author (${reviewAuthor}) does not match dismisser (${dismisser}). ` + + `Actor-bound dismissal only permits dismissing reviews authored by the current workflow actor.`; + core.warning(warningMessage); + return { + success: false, + skipped: true, + error: warningMessage, + }; + } return { success: false, error: `review author (${reviewAuthor || "unknown"}) must match dismisser (${dismisser})`, diff --git a/setup/js/frontmatter_hash_pure.cjs b/setup/js/frontmatter_hash_pure.cjs index e8d6d38..2110063 100644 --- a/setup/js/frontmatter_hash_pure.cjs +++ b/setup/js/frontmatter_hash_pure.cjs @@ -26,7 +26,7 @@ async function defaultFileReader(filePath) { try { return fs.readFileSync(filePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${filePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } } diff --git a/setup/js/generate_aw_info.cjs b/setup/js/generate_aw_info.cjs index 89b97a6..078f210 100644 --- a/setup/js/generate_aw_info.cjs +++ b/setup/js/generate_aw_info.cjs @@ -181,14 +181,14 @@ async function main(core, ctx) { try { fs.mkdirSync(TMP_GH_AW_PATH, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${TMP_GH_AW_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${TMP_GH_AW_PATH}: ${getErrorMessage(err)}`, { cause: err }); } writeMergedModelsJSON(core); const tmpPath = TMP_GH_AW_PATH + "/aw_info.json"; try { fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); } catch (err) { - throw new Error(`Failed to write file ${tmpPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${tmpPath}: ${getErrorMessage(err)}`, { cause: err }); } if (awInfo.staged) { diff --git a/setup/js/generate_footer.cjs b/setup/js/generate_footer.cjs index 77e5af6..7c1413d 100644 --- a/setup/js/generate_footer.cjs +++ b/setup/js/generate_footer.cjs @@ -2,6 +2,7 @@ /// const { getDetectionReasonText, getThreatDetectedMarker, isToolingFailureReason } = require("./threat_detection_warning.cjs"); +const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); /** * Generates a standalone workflow-id XML comment marker for searchability. @@ -113,12 +114,7 @@ function generateXMLMarker(workflowName, runUrl) { * admonition is used so reviewers can distinguish "detection engine crashed" from "detection * engine found something". Actual threat findings (threat_detected) keep [!CAUTION]. * - * Note: This function is intentionally kept inline (not imported from messages_footer.cjs) - * because importing messages_footer.cjs here would cause the bundler to inline - * messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in a warning message, - * breaking tests that check for env var declarations. - * - * Warning reason text and threat marker formatting are centralized in + * Note: Warning reason text and threat marker formatting are centralized in * threat_detection_warning.cjs to keep warning-mode messaging consistent. * * @param {string} workflowName - Name of the workflow @@ -132,10 +128,11 @@ function getExpiredEntityCautionAlert(workflowName, runUrl) { } const detectionReason = process.env.GH_AW_DETECTION_REASON || ""; const reasonText = getDetectionReasonText(detectionReason); + const context = { threat_detected_marker: getThreatDetectedMarker(detectionReason), reason_text: reasonText, run_url: runUrl }; if (isToolingFailureReason(detectionReason)) { - return `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatDetectedMarker(detectionReason)}\n>\n>
\n> Details\n>\n> ${reasonText}\n>\n> Review the [workflow run logs](${runUrl}) for details.\n>
`; + return renderTemplateFromFile(getPromptPath("threat_detection_engine_error.md"), context).trimEnd(); } - return `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarker(detectionReason)}\n>\n>
\n> Details\n>\n> ${reasonText}\n>\n> Review the [workflow run logs](${runUrl}) for details.\n>
`; + return renderTemplateFromFile(getPromptPath("threat_detection_caution.md"), context).trimEnd(); } /** diff --git a/setup/js/generate_git_bundle.cjs b/setup/js/generate_git_bundle.cjs index d707327..7d0e010 100644 --- a/setup/js/generate_git_bundle.cjs +++ b/setup/js/generate_git_bundle.cjs @@ -136,7 +136,7 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { try { fs.mkdirSync(bundleDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${bundleDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${bundleDir}: ${getErrorMessage(err)}`, { cause: err }); } } diff --git a/setup/js/generate_git_patch.cjs b/setup/js/generate_git_patch.cjs index 2132a2d..f2d1a7e 100644 --- a/setup/js/generate_git_patch.cjs +++ b/setup/js/generate_git_patch.cjs @@ -137,7 +137,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { try { fs.mkdirSync(patchDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${patchDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${patchDir}: ${getErrorMessage(err)}`, { cause: err }); } } @@ -487,7 +487,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { try { patchContent = fs.readFileSync(patchPath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchPath}: ${getErrorMessage(err)}`, { cause: err }); } const patchSize = Buffer.byteLength(patchContent, "utf8"); const patchLines = patchContent.split("\n").length; diff --git a/setup/js/generate_observability_summary.cjs b/setup/js/generate_observability_summary.cjs index 680a564..73aaa27 100644 --- a/setup/js/generate_observability_summary.cjs +++ b/setup/js/generate_observability_summary.cjs @@ -8,6 +8,8 @@ const AGENT_OUTPUT_PATH = "/tmp/gh-aw/agent_output.json"; const OTLP_EXPORT_ERRORS_PATH = "/tmp/gh-aw/otlp-export-errors.count"; const OTLP_EXPORT_ERROR_DETAILS_PATH = "/tmp/gh-aw/otlp-export-errors.jsonl"; const gatewayEventPaths = ["/tmp/gh-aw/mcp-logs/gateway.jsonl", "/tmp/gh-aw/mcp-logs/rpc-messages.jsonl"]; +// Squid access log paths: current AWF layout (squid-logs/ subdirectory) and legacy layout (directly under logs/). +const squidAccessLogPaths = ["/tmp/gh-aw/sandbox/firewall/logs/squid-logs/access.log", "/tmp/gh-aw/sandbox/firewall/logs/access.log"]; function readJSONIfExists(path) { if (!fs.existsSync(path)) { @@ -63,6 +65,15 @@ function uniqueCreatedItemTypes(items) { return [...types].sort(); } +function checkSquidAccessLogPresent() { + for (const path of squidAccessLogPaths) { + if (fs.existsSync(path)) { + return true; + } + } + return false; +} + function readOTLPExportErrorCount() { if (!fs.existsSync(OTLP_EXPORT_ERRORS_PATH)) { return 0; @@ -117,12 +128,14 @@ function collectObservabilityData() { // Do NOT fall back to workflow_call_id — it is not a valid OTLP trace ID. const traceId = process.env.GITHUB_AW_OTEL_TRACE_ID || (awInfo.context ? awInfo.context.otel_trace_id || "" : ""); + const firewallEnabled = awInfo.firewall_enabled === true; return { workflowName: awInfo.workflow_name || "", engineId: awInfo.engine_id || "", traceId, staged: awInfo.staged === true, - firewallEnabled: awInfo.firewall_enabled === true, + firewallEnabled, + squidAccessLogPresent: firewallEnabled ? checkSquidAccessLogPresent() : null, createdItemCount: items.length, createdItemTypes: uniqueCreatedItemTypes(items), outputErrorCount: errors.length, @@ -156,6 +169,12 @@ function buildObservabilitySummary(data) { lines.push(`- **agent output errors**: ${data.outputErrorCount}`); lines.push(`- **otlp export errors**: ${data.otlpExportErrors}`); lines.push(`- **firewall enabled**: ${data.firewallEnabled}`); + if (data.firewallEnabled && data.squidAccessLogPresent !== null) { + lines.push(`- **squid access.log present**: ${data.squidAccessLogPresent}`); + if (!data.squidAccessLogPresent) { + lines.push("- Squid access.log not found; egress traffic for this run cannot be audited."); + } + } lines.push(`- **staged**: ${data.staged}`); if (data.otlpExportErrors > 0) { diff --git a/setup/js/generate_safe_outputs_tools.cjs b/setup/js/generate_safe_outputs_tools.cjs index 8d91f5a..9f201cb 100644 --- a/setup/js/generate_safe_outputs_tools.cjs +++ b/setup/js/generate_safe_outputs_tools.cjs @@ -212,7 +212,7 @@ async function main() { try { fs.writeFileSync(toolsMetaPath, process.env.GH_AW_TOOLS_META_JSON); } catch (err) { - throw new Error(`Failed to write file ${toolsMetaPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${toolsMetaPath}: ${getErrorMessage(err)}`, { cause: err }); } } if (process.env.GH_AW_VALIDATION_JSON) { @@ -220,7 +220,7 @@ async function main() { try { fs.writeFileSync(validationPath, process.env.GH_AW_VALIDATION_JSON); } catch (err) { - throw new Error(`Failed to write file ${validationPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${validationPath}: ${getErrorMessage(err)}`, { cause: err }); } } @@ -396,7 +396,7 @@ async function main() { try { fs.writeFileSync(outputPath, JSON.stringify(allFilteredTools, null, 2)); } catch (err) { - throw new Error(`Failed to write file ${outputPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${outputPath}: ${getErrorMessage(err)}`, { cause: err }); } const debugEnabled = process.env.DEBUG === "*" || (process.env.DEBUG || "").includes("safe_outputs"); diff --git a/setup/js/generate_usage_activity_summary.cjs b/setup/js/generate_usage_activity_summary.cjs index 7298bd8..14e3316 100644 --- a/setup/js/generate_usage_activity_summary.cjs +++ b/setup/js/generate_usage_activity_summary.cjs @@ -7,10 +7,12 @@ // session: aggregate Copilot session event counters // gateway: total/failed tool-call counters with per-server breakdown // safe_outputs: total item count and per-type breakdown from safe-output-items manifest +// experiments: A/B experiment variant assignments for the current run const fs = require("fs"); const { globSync } = require("node:fs"); const path = require("path"); +const { readExperimentAssignments } = require("./experiment_helpers.cjs"); require("./shim.cjs"); @@ -381,6 +383,12 @@ function parseGatewayLogs() { * Reads the JSONL file written by the safe_outputs job and downloaded into * the conclusion job via the safe-outputs-items artifact. * + * Three distinct return states let callers distinguish artifact provenance: + * • returns null → manifest file not found + * • returns { total_items: 0, ... } → manifest present but contained no loggable items + * • returns { total_items: N, ... } → manifest present with N items + * • throws → manifest file exists but could not be read + * * @param {string} [manifestPath] - Path to the manifest file (defaults to MANIFEST_FILE_PATH) * @returns {{ total_items: number, items_by_type: Record } | null} */ @@ -391,12 +399,9 @@ function parseSafeOutputsManifest(manifestPath = MANIFEST_FILE_PATH) { return null; } - let content; - try { - content = fs.readFileSync(manifestPath, "utf-8"); - } catch (err) { - return null; - } + // Let read errors propagate so the caller can distinguish "unreadable file" + // from "file present but no items" — both previously collapsed to null. + const content = fs.readFileSync(manifestPath, "utf-8"); const itemsByType = {}; let totalItems = 0; @@ -423,16 +428,27 @@ function parseSafeOutputsManifest(manifestPath = MANIFEST_FILE_PATH) { itemsByType[itemType] = (itemsByType[itemType] || 0) + 1; } - if (totalItems === 0) { - return null; - } - return { total_items: totalItems, items_by_type: itemsByType, }; } +/** + * Parse A/B experiment assignments for the current run. + * Reads the assignments.json file written by pick_experiment.cjs. + * Returns null when no experiments are active for this run. + * + * @returns {{ assignments: Record } | null} + */ +function parseExperimentsData() { + const assignments = readExperimentAssignments(); + if (!assignments || Object.keys(assignments).length === 0) { + return null; + } + return { assignments }; +} + /** * Main function to generate usage activity summary */ @@ -457,10 +473,34 @@ function main() { summary.gateway = gateway; } - // Parse safe outputs manifest - const safeOutputs = parseSafeOutputsManifest(); - if (safeOutputs) { - summary.safe_outputs = safeOutputs; + // Parse safe outputs manifest. + // parseSafeOutputsManifest() has three distinct outcomes that drive the three + // states downstream consumers need to distinguish: + // • safe_outputs absent → manifest not found (artifact download failed or job never ran) + // • safe_outputs.total_items == 0 → manifest present, no items logged + // • safe_outputs.total_items > 0 → manifest present with N items + // A read error is kept separate: it logs a warning but omits safe_outputs so + // the consumer cannot mistake a broken artifact for a legitimately empty one. + try { + const safeOutputs = parseSafeOutputsManifest(); + if (safeOutputs === null) { + core.info(`safe-output-items manifest not found at ${MANIFEST_FILE_PATH} — safe-outputs-items artifact may not have been downloaded`); + } else { + summary.safe_outputs = safeOutputs; + if (safeOutputs.total_items === 0) { + core.info(`safe-output-items manifest: 0 item(s) logged (file present but contained no loggable items)`); + } else { + core.info(`safe-output-items manifest: ${safeOutputs.total_items} item(s) logged (types: ${Object.keys(safeOutputs.items_by_type).join(", ")})`); + } + } + } catch (err) { + core.warning(`safe-output-items manifest could not be read from ${MANIFEST_FILE_PATH}: ${String(err)} — safe_outputs omitted from summary`); + } + + // Include A/B experiment assignments so the CLI can read them from the usage artifact. + const experiments = parseExperimentsData(); + if (experiments) { + summary.experiments = experiments; } // Write summary to file @@ -478,4 +518,4 @@ if (require.main === module) { main(); } -module.exports = { parseFirewallLogs, parseSessionLogs, parseGatewayLogs, parseSafeOutputsManifest, MANIFEST_FILE_PATH }; +module.exports = { parseFirewallLogs, parseSessionLogs, parseGatewayLogs, parseSafeOutputsManifest, parseExperimentsData, MANIFEST_FILE_PATH }; diff --git a/setup/js/git_helpers.cjs b/setup/js/git_helpers.cjs index bfe3db5..c98cd55 100644 --- a/setup/js/git_helpers.cjs +++ b/setup/js/git_helpers.cjs @@ -712,6 +712,9 @@ async function backfillCommitObjects(execApi, commitShas, options = {}) { * invocation (e.g. `["--allow-empty", "--no-verify"]`). * @param {string[]} [opts.excludedFiles] - Paths that should be removed from the staged rewrite * before creating the linearized commit. + * @param {string} [opts.rebaseOnto] - Optional ref to replay the synthesized commit onto after + * it has been linearized relative to `baseRef`. Use this when `baseRef` captures the agent's + * actual change base but the resulting single commit must sit on a newer branch tip. * @param {number} [opts.maxCommits] - Override the implausibility threshold (default * `SHALLOW_RANGE_MAX_COMMITS`). Set to `Infinity` to disable the shallow guard. * @returns {Promise} The new HEAD SHA after the rewrite. @@ -719,7 +722,7 @@ async function backfillCommitObjects(execApi, commitShas, options = {}) { * shallow checkout produces an implausible commit range. */ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}) { - const { gitOpts, commitFlags = [], excludedFiles = [], maxCommits = SHALLOW_RANGE_MAX_COMMITS } = opts; + const { gitOpts, commitFlags = [], excludedFiles = [], rebaseOnto, maxCommits = SHALLOW_RANGE_MAX_COMMITS } = opts; // Spread gitOpts into exec calls only when it is explicitly provided — passing // `undefined` as a third argument changes the arity seen by mocks in tests. const execArgs = gitOpts !== undefined ? [gitOpts] : []; @@ -762,12 +765,30 @@ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {} throw new Error("Could not resolve current HEAD before linearizing range"); } + // Track whether a `git rebase` call was started so the catch block can distinguish + // "rebase in progress" (needs --abort) from "pre-rebase failure" (needs reset only). + let rebaseStarted = false; try { await execApi.exec("git", ["reset", "--soft", baseRef], ...execArgs); if (Array.isArray(excludedFiles) && excludedFiles.length > 0) { const { stdout: excludedStagedOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only", "--", ...excludedFiles], ...execArgs); if (excludedStagedOut.trim()) { - await execApi.exec("git", ["checkout", "HEAD", "--", ...excludedFiles], ...execArgs); + // Use `git reset HEAD -- ` rather than `git checkout HEAD -- `. + // For newly-added excluded files (not present in HEAD), `checkout` fails with + // "pathspec did not match any file(s) known to git". `reset HEAD --` handles + // both cases: removes new files from the index and restores modified files to + // the HEAD version, without touching the working tree. + await execApi.exec("git", ["reset", "HEAD", "--", ...excludedFiles], ...execArgs); + // For excluded files that were modifications (not new additions), the working tree + // still has the agent's version while the index was just restored to HEAD. This + // creates an unstaged change that would cause `git rebase --onto` to fail. + // Detect any such unstaged changes among the excluded files and restore them from + // the index so the working tree stays in sync before the commit and rebase steps. + const { stdout: modifiedExcludedOut } = await execApi.getExecOutput("git", ["diff", "--name-only", "--", ...excludedFiles], ...execArgs); + const modifiedExcluded = modifiedExcludedOut.trim().split("\n").filter(Boolean); + if (modifiedExcluded.length > 0) { + await execApi.exec("git", ["checkout", "--", ...modifiedExcluded], ...execArgs); + } } } const { stdout: stagedFilesOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only"], ...execArgs); @@ -775,10 +796,31 @@ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {} throw new Error(`No staged changes found after soft reset to ${baseRef}. ` + `The commit range may contain only no-op or empty commits. ` + `Ensure your commits contain actual file changes before pushing.`); } await execApi.exec("git", ["commit", ...commitFlags, "-m", commitMessage], ...execArgs); + if (typeof rebaseOnto === "string" && rebaseOnto.trim() && rebaseOnto.trim() !== baseRef.trim()) { + rebaseStarted = true; + await execApi.exec("git", ["rebase", "--onto", rebaseOnto.trim(), baseRef, "HEAD"], ...execArgs); + rebaseStarted = false; + // Guard: if the rebase silently dropped the commit (became empty relative to rebaseOnto), + // the agent's changes are lost. Detect and fail loudly rather than pushing an empty diff. + const { stdout: diffOut } = await execApi.getExecOutput("git", ["diff", "--name-only", rebaseOnto.trim(), "HEAD"], ...execArgs); + if (!diffOut.trim()) { + throw new Error(`Rebase onto ${rebaseOnto} produced no changes; the synthesized commit was dropped as empty`); + } + } const { stdout: newHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"], ...execArgs); return newHeadOut.trim(); } catch (rewriteError) { try { + if (rebaseStarted) { + // A rebase was in progress when the error occurred; abort it to restore the repo to its + // pre-rebase state before the hard reset below finishes the rollback. + try { + await execApi.exec("git", ["rebase", "--abort"], ...execArgs); + } catch (abortError) { + // --abort failed while a rebase was genuinely in progress — repo may be in a dirty state. + core.error(`linearizeRangeAsCommit: rebase --abort also failed: ${getErrorMessage(abortError)}`); + } + } await execApi.exec("git", ["reset", "--hard", originalHead], ...execArgs); core.warning(`linearizeRangeAsCommit: rewrite failed; restored original HEAD ${originalHead}`); } catch (restoreError) { diff --git a/setup/js/github_rate_limit_logger.cjs b/setup/js/github_rate_limit_logger.cjs index 10bdc87..c5d7cac 100644 --- a/setup/js/github_rate_limit_logger.cjs +++ b/setup/js/github_rate_limit_logger.cjs @@ -44,7 +44,7 @@ function ensureDir(filePath) { try { fs.mkdirSync(dir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${dir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${dir}: ${getErrorMessage(err)}`, { cause: err }); } } } diff --git a/setup/js/glob_pattern_helpers.cjs b/setup/js/glob_pattern_helpers.cjs index 416470b..7f8f41d 100644 --- a/setup/js/glob_pattern_helpers.cjs +++ b/setup/js/glob_pattern_helpers.cjs @@ -71,6 +71,7 @@ function globPatternToRegex(pattern, options) { regexPattern = `[^/]+/${regexPattern}`; } + // eslint-disable-next-line gh-aw-custom/require-escaped-regexp-interpolation -- regexPattern is intentionally built as a regex: * and ** are replaced with regex patterns after escaping all other metacharacters return new RegExp(`^${regexPattern}$`, caseSensitive ? "" : "i"); } diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 09e7fde..3b75e2c 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -1500,7 +1500,7 @@ function buildToolDenialsExceededContext(events, workflowId) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return ( "\n" + @@ -1672,7 +1672,7 @@ function buildInferenceAccessErrorContext(hasInferenceAccessError) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return "\n" + template; } @@ -2139,7 +2139,7 @@ function buildLockdownCheckFailedContext(hasLockdownCheckFailed) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return "\n" + template; } @@ -2162,7 +2162,7 @@ function buildOAuthTokenCheckFailedContext(hasOAuthTokenCheckFailed, runUrl) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return "\n" + renderTemplate(template, { run_url: runUrl }); } @@ -2184,7 +2184,7 @@ function buildStaleLockFileFailedContext(hasStaleLockFileFailed) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return "\n" + template; } diff --git a/setup/js/handle_detection_runs.cjs b/setup/js/handle_detection_runs.cjs index 9aba6fb..44f9725 100644 --- a/setup/js/handle_detection_runs.cjs +++ b/setup/js/handle_detection_runs.cjs @@ -49,7 +49,7 @@ async function ensureDetectionRunsIssue() { try { parentBodyContent = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } const parentBody = generateFooterWithExpiration({ diff --git a/setup/js/handle_noop_message.cjs b/setup/js/handle_noop_message.cjs index cd55e5b..4de6d9d 100644 --- a/setup/js/handle_noop_message.cjs +++ b/setup/js/handle_noop_message.cjs @@ -54,7 +54,7 @@ async function ensureAgentRunsIssue() { try { parentBodyContent = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } const parentBody = generateFooterWithExpiration({ @@ -78,24 +78,40 @@ async function ensureAgentRunsIssue() { } /** - * Build the AIC suffix string for use in comment footers. - * Includes agent, threat-detection, and evals AIC when available. - * Returns a string like " · 0.001 AIC" or "" when not available. + * Parse a raw AIC environment variable value and return it as a positive number. + * Returns undefined when the value is absent, non-numeric, or non-positive. + * @param {string|undefined} raw + * @returns {number|undefined} + */ +function parsePositiveAIC(raw) { + const parsed = raw ? Number.parseFloat(raw) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +/** + * @param {string} label + * @param {number|undefined} value + * @param {string|undefined} [modelAlias] * @returns {string} */ -function buildAICSuffix() { - const agentRaw = process.env.GH_AW_AIC; - const detectionRaw = process.env.GH_AW_THREAT_DETECTION_AIC; - const evalsRaw = process.env.GH_AW_EVALS_AIC; - const agentAIC = agentRaw ? Number.parseFloat(agentRaw) : NaN; - const detectionAIC = detectionRaw ? Number.parseFloat(detectionRaw) : NaN; - const evalsAIC = evalsRaw ? Number.parseFloat(evalsRaw) : NaN; - const compressedModelName = reduceModelNameToIdentifier(process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL); - const totalAIC = (Number.isFinite(agentAIC) && agentAIC > 0 ? agentAIC : 0) + (Number.isFinite(detectionAIC) && detectionAIC > 0 ? detectionAIC : 0) + (Number.isFinite(evalsAIC) && evalsAIC > 0 ? evalsAIC : 0); - if (totalAIC <= 0) { +function buildAICEntry(label, value, modelAlias) { + const formatted = typeof value === "number" ? formatAIC(value) : ""; + if (!formatted) { return ""; } - return ` · ${compressedModelName ? `${compressedModelName} · ` : ""}${formatAIC(totalAIC)} AIC`; + const prefix = [label, modelAlias].filter(Boolean).join(" "); + return ` · ${prefix ? `${prefix}${modelAlias ? " · " : " "}` : ""}${formatted} AIC`; +} + +function buildAICSuffix() { + const agentAIC = parsePositiveAIC(process.env.GH_AW_AIC); + const detectionAIC = parsePositiveAIC(process.env.GH_AW_THREAT_DETECTION_AIC); + const evalsAIC = parsePositiveAIC(process.env.GH_AW_EVALS_AIC); + const compressedModelName = reduceModelNameToIdentifier(process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL); + const agentSuffix = buildAICEntry("", agentAIC, compressedModelName); + const detectionSuffix = buildAICEntry("⌖", detectionAIC); + const evalsSuffix = buildAICEntry("◇", evalsAIC); + return `${agentSuffix}${detectionSuffix}${evalsSuffix}`; } /** diff --git a/setup/js/install_frontmatter_skills.cjs b/setup/js/install_frontmatter_skills.cjs index 4d11a96..83ac75f 100644 --- a/setup/js/install_frontmatter_skills.cjs +++ b/setup/js/install_frontmatter_skills.cjs @@ -179,7 +179,7 @@ async function main() { try { fs.mkdirSync(skillsDst, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${skillsDst}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${skillsDst}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Installing frontmatter skills to ${skillsDst}`); diff --git a/setup/js/load_experiment_state_from_repo.cjs b/setup/js/load_experiment_state_from_repo.cjs index 8e6b347..efeddf7 100644 --- a/setup/js/load_experiment_state_from_repo.cjs +++ b/setup/js/load_experiment_state_from_repo.cjs @@ -12,7 +12,7 @@ * * Environment variables (set by the compiled workflow step): * GH_AW_EXPERIMENT_STATE_FILE - Absolute path to the local state file to write - * e.g. /tmp/gh-aw/experiments/state.json + * e.g. /tmp/gh-aw/experiments/state.jsonl * GH_AW_EXPERIMENT_STATE_DIR - Directory that holds the state file (created if missing) * e.g. /tmp/gh-aw/experiments * GH_AW_EXPERIMENT_BRANCH - Git branch name to fetch state from @@ -29,6 +29,31 @@ const MAX_STATE_FILE_BYTES = 102400; const BRANCH_NAME_PATTERN = /^[A-Za-z0-9._/-]+$/; const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +function isExperimentStateContentValid(content) { + try { + const parsed = JSON.parse(content); + return !!parsed && typeof parsed.counts === "object"; + } catch {} + + try { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const entry = JSON.parse(trimmed); + const isSnapshot = !!entry && typeof entry.counts === "object"; + const isRunRecord = !!entry && typeof entry.run_id === "string" && typeof entry.timestamp === "string" && entry.assignments && typeof entry.assignments === "object" && !Array.isArray(entry.assignments); + if (!isSnapshot && !isRunRecord) { + return false; + } + } + return true; + } catch { + return false; + } +} + /** * Returns true when decoded state content exceeds allowed byte length. * @@ -100,7 +125,7 @@ async function fetchFileFromBranch(octokit, owner, repo, branch, filePath) { if (errAny.status === 404) { return null; } - throw new Error(`${ERR_API}: Failed to fetch file "${filePath}" from branch "${branch}": ${String(err)}`, { cause: err }); + throw new Error(`${ERR_API}: Failed to fetch file "${filePath}" from branch "${branch}": ${getErrorMessage(err)}`, { cause: err }); } } @@ -108,7 +133,7 @@ async function fetchFileFromBranch(octokit, owner, repo, branch, filePath) { * Main entry point called by the actions/github-script step. */ async function main() { - const stateFile = process.env.GH_AW_EXPERIMENT_STATE_FILE || "/tmp/gh-aw/experiments/state.json"; + const stateFile = process.env.GH_AW_EXPERIMENT_STATE_FILE || "/tmp/gh-aw/experiments/state.jsonl"; const stateDir = process.env.GH_AW_EXPERIMENT_STATE_DIR || "/tmp/gh-aw/experiments"; const branch = process.env.GH_AW_EXPERIMENT_BRANCH || ""; const repository = process.env.GITHUB_REPOSITORY || ""; @@ -120,7 +145,7 @@ async function main() { try { fs.mkdirSync(stateDir, { recursive: true }); } catch (err) { - throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stateDir}: ${String(err)}`, { cause: err }); + throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stateDir}: ${getErrorMessage(err)}`, { cause: err }); } return; } @@ -129,13 +154,19 @@ async function main() { // This avoids requiring GITHUB_TOKEN to be explicitly set in the step env. const octokit = github; const stateFileName = path.basename(stateFile); + const stateFileCandidates = Array.from(new Set(stateFileName === "state.json" ? ["state.jsonl", "state.json"] : stateFileName === "state.jsonl" ? ["state.jsonl", "state.json"] : [stateFileName])); - core.info(`Loading experiment state from branch "${branch}" (file: ${stateFileName})`); + core.info(`Loading experiment state from branch "${branch}" (file: ${stateFileCandidates.join(" or ")})`); /** @type {any} */ let content = null; try { - content = await fetchFileFromBranch(octokit, owner, repo, branch, stateFileName); + for (const candidate of stateFileCandidates) { + content = await fetchFileFromBranch(octokit, owner, repo, branch, candidate); + if (content !== null) { + break; + } + } } catch (/** @type {any} */ err) { core.warning(`Failed to fetch experiment state from branch "${branch}": ${getErrorMessage(err)} – starting fresh`); } @@ -144,7 +175,7 @@ async function main() { try { fs.mkdirSync(stateDir, { recursive: true }); } catch (err) { - throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stateDir}: ${String(err)}`, { cause: err }); + throw new Error(`${ERR_SYSTEM}: Failed to create directory ${stateDir}: ${getErrorMessage(err)}`, { cause: err }); } if (content === null) { @@ -157,14 +188,7 @@ async function main() { return; } - // Validate that the content is parseable JSON before writing. - try { - const parsed = JSON.parse(content); - if (!parsed || typeof parsed.counts !== "object") { - core.warning(`Experiment state in branch "${branch}" is invalid JSON – starting fresh`); - return; - } - } catch { + if (!isExperimentStateContentValid(content)) { core.warning(`Experiment state in branch "${branch}" could not be parsed – starting fresh`); return; } @@ -172,7 +196,7 @@ async function main() { try { fs.writeFileSync(stateFile, content, "utf8"); } catch (err) { - throw new Error(`${ERR_SYSTEM}: Failed to write file ${stateFile}: ${String(err)}`, { cause: err }); + throw new Error(`${ERR_SYSTEM}: Failed to write file ${stateFile}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Experiment state written to ${stateFile}`); } diff --git a/setup/js/mcp_scripts_config_loader.cjs b/setup/js/mcp_scripts_config_loader.cjs index 6b03c68..85ac1fc 100644 --- a/setup/js/mcp_scripts_config_loader.cjs +++ b/setup/js/mcp_scripts_config_loader.cjs @@ -44,7 +44,7 @@ function loadConfig(configPath) { try { configContent = fs.readFileSync(configPath, "utf-8"); } catch (err) { - throw new Error(`Failed to read file ${configPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${configPath}: ${getErrorMessage(err)}`, { cause: err }); } let config; try { diff --git a/setup/js/merge_remote_agent_github_folder.cjs b/setup/js/merge_remote_agent_github_folder.cjs index c2df852..3786796 100644 --- a/setup/js/merge_remote_agent_github_folder.cjs +++ b/setup/js/merge_remote_agent_github_folder.cjs @@ -262,7 +262,7 @@ function mergeGithubFolder(sourcePath, destPath) { sourceContent = fs.readFileSync(sourceFile); destContent = fs.readFileSync(destFile); } catch (err) { - throw new Error(`Failed to read file for merge conflict detection: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file for merge conflict detection: ${getErrorMessage(err)}`, { cause: err }); } if (!sourceContent.equals(destContent)) { @@ -278,7 +278,7 @@ function mergeGithubFolder(sourcePath, destPath) { try { fs.mkdirSync(destDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${destDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${destDir}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Created directory: ${path.relative(destPath, destDir)}`); } @@ -335,7 +335,7 @@ async function mergeRepositoryGithubFolder(owner, repo, ref, workspace) { try { fs.mkdirSync(destGithubFolder, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${destGithubFolder}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${destGithubFolder}: ${getErrorMessage(err)}`, { cause: err }); } core.info("Created .github folder in workspace"); } @@ -453,7 +453,7 @@ async function main() { // Run if executed directly (not imported) if (require.main === module) { main().catch(err => { - core.setFailed(err && err.stack ? err.stack : String(err)); + core.setFailed(err && err.stack ? err.stack : getErrorMessage(err)); }); } diff --git a/setup/js/messages_core.cjs b/setup/js/messages_core.cjs index 1203f51..dd47d0c 100644 --- a/setup/js/messages_core.cjs +++ b/setup/js/messages_core.cjs @@ -143,7 +143,7 @@ function renderTemplateFromFile(templatePath, context) { try { template = fs.readFileSync(templatePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } return renderTemplate(template, context); } diff --git a/setup/js/messages_run_status.cjs b/setup/js/messages_run_status.cjs index 445e7cf..2834b3e 100644 --- a/setup/js/messages_run_status.cjs +++ b/setup/js/messages_run_status.cjs @@ -7,8 +7,8 @@ * for workflow execution notifications. */ -const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs"); -const { getDetectionReasonText, getThreatDetectedMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs"); +const { getMessages, renderTemplate, renderTemplateFromFile, toSnakeCase, getPromptPath } = require("./messages_core.cjs"); +const { getDetectionReasonText, getThreatDetectedMarkerTemplate, getThreatEngineErrorMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs"); /** * Renders a message using a custom template from config or a default template. @@ -153,12 +153,15 @@ function getCommitPushedMessage(ctx) { function getDetectionWarningMessage(ctx) { const reasonText = getDetectionReasonText(ctx.reason); const isEngineError = isToolingFailureReason(ctx.reason); - if (isEngineError) { - const defaultTemplate = `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatDetectedMarkerTemplate()}\n>\n>
\n> Details\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n>
`; - return renderConfiguredMessage("detectionEngineError", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) }); + const templateFile = isEngineError ? "threat_detection_engine_error.md" : "threat_detection_caution.md"; + const messageKey = isEngineError ? "detectionEngineError" : "detectionWarning"; + const markerTemplate = isEngineError ? getThreatEngineErrorMarkerTemplate() : getThreatDetectedMarkerTemplate(); + const messages = getMessages(); + const configTemplate = messages?.[messageKey]; + if (configTemplate) { + return renderTemplate(configTemplate, toSnakeCase({ ...ctx, reasonText, threat_detected_marker: markerTemplate, threatKinds: normalizeThreatKinds(ctx.reason) })); } - const defaultTemplate = `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarkerTemplate()}\n>\n>
\n> Details\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n>
`; - return renderConfiguredMessage("detectionWarning", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) }); + return renderTemplateFromFile(getPromptPath(templateFile), toSnakeCase({ ...ctx, reasonText, threat_detected_marker: markerTemplate, threatKinds: normalizeThreatKinds(ctx.reason) })).trimEnd(); } module.exports = { diff --git a/setup/js/models.json b/setup/js/models.json index eb8c4b7..c1a768e 100644 --- a/setup/js/models.json +++ b/setup/js/models.json @@ -480,7 +480,7 @@ "cost": { "input": "2e-07", "output": "1.2e-06", - "cache_read": "1e-07" + "cache_read": "2e-08" }, "provider_type": "openai", "wire_api": "responses" @@ -498,7 +498,7 @@ "cost": { "input": "2e-06", "output": "1.2e-05", - "cache_read": "2.5e-07" + "cache_read": "2e-07" }, "provider_type": "openai", "wire_api": "responses" @@ -828,6 +828,30 @@ }, "provider_type": "openai" }, + "gpt-5.6-luna": { + "cost": { + "input": "2e-07", + "output": "1.2e-06", + "cache_read": "2e-08" + }, + "provider_type": "openai" + }, + "gpt-5.6-sol": { + "cost": { + "input": "5e-06", + "output": "3e-05", + "cache_read": "5e-07" + }, + "provider_type": "openai" + }, + "gpt-5.6-terra": { + "cost": { + "input": "2e-06", + "output": "1.2e-05", + "cache_read": "2e-07" + }, + "provider_type": "openai" + }, "gpt-image-1": { "cost": {}, "provider_type": "openai" diff --git a/setup/js/mount_mcp_as_cli.cjs b/setup/js/mount_mcp_as_cli.cjs index 8116491..0aabc9b 100644 --- a/setup/js/mount_mcp_as_cli.cjs +++ b/setup/js/mount_mcp_as_cli.cjs @@ -40,6 +40,18 @@ const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; /** Default timeout (ms) for HTTP calls to the local MCP gateway */ const DEFAULT_HTTP_TIMEOUT_MS = 15000; +/** + * Maximum number of times to retry tools/list when a server returns 0 tools. + * The gateway may report a backend as "running" before the backend has finished + * building its tool schema (a race condition more likely with large configs). + */ +const TOOLS_EMPTY_MAX_RETRIES = 5; + +/** + * Milliseconds to wait between tools/list retry attempts when the result is empty. + */ +const TOOLS_EMPTY_RETRY_DELAY_MS = 1000; + /** * Parse a tools JSON file and return a validated tools array. * @@ -319,9 +331,9 @@ function parseMCPResponseBody(body) { * @param {string} serverUrl - HTTP URL of the MCP server endpoint * @param {string} apiKey - Bearer token for gateway authentication * @param {typeof import("@actions/core")} core - GitHub Actions core - * @returns {Promise>} + * @returns {Promise<{tools: Array<{name: string, description?: string, inputSchema?: unknown}>, emptyWasSuccessful: boolean}>} */ -async function fetchMCPTools(serverUrl, apiKey, core) { +async function fetchMCPToolsResult(serverUrl, apiKey, core) { const authHeaders = { Authorization: apiKey }; // Step 1: initialize – establish the session and capture Mcp-Session-Id if present @@ -349,7 +361,7 @@ async function fetchMCPTools(serverUrl, apiKey, core) { } } catch (err) { core.warning(` initialize failed for ${serverUrl}: ${getErrorMessage(err)}`); - return []; + return { tools: [], emptyWasSuccessful: false }; } // Step 2: notifications/initialized – required by MCP spec to complete the handshake. @@ -367,14 +379,79 @@ async function fetchMCPTools(serverUrl, apiKey, core) { if (respBody && typeof respBody === "object" && "result" in respBody && respBody.result && typeof respBody.result === "object") { const result = respBody.result; if ("tools" in result && Array.isArray(result.tools)) { - return /** @type {Array<{name: string, description?: string, inputSchema?: unknown}>} */ result.tools; + return { + tools: /** @type {Array<{name: string, description?: string, inputSchema?: unknown}>} */ result.tools, + emptyWasSuccessful: true, + }; } } - return []; + return { tools: [], emptyWasSuccessful: false }; } catch (err) { core.warning(` tools/list failed for ${serverUrl}: ${getErrorMessage(err)}`); - return []; + return { tools: [], emptyWasSuccessful: false }; + } +} + +/** + * Query the tools list from an MCP server via JSON-RPC. + * + * @param {string} serverUrl - HTTP URL of the MCP server endpoint + * @param {string} apiKey - ****** for gateway authentication + * @param {typeof import("@actions/core")} core - GitHub Actions core + * @returns {Promise>} + */ +async function fetchMCPTools(serverUrl, apiKey, core) { + const result = await fetchMCPToolsResult(serverUrl, apiKey, core); + return result.tools; +} + +/** + * Fetch MCP tools with retry on empty result. + * + * The MCP gateway may report a backend as "running" before that backend has + * finished building its internal tool schema (a race between process-level + * readiness and schema construction). This is more likely with large + * dispatch-workflow configs where building tool definitions takes long enough + * that tools/list can still return 0 tools immediately after the health check + * passes. Retrying a handful of times with a short delay bridges that gap, but + * only for successful empty tools/list responses; transport/protocol failures + * stop immediately so unavailable backends still fail fast. + * + * @param {string} serverUrl + * @param {string} apiKey + * @param {string} serverName - Server name, used only for log messages + * @param {typeof import("@actions/core")} core + * @param {object} [options] + * @param {(ms: number) => Promise} [options.sleep] - Delay function (injectable for tests) + * @param {(url: string, key: string, c: typeof import("@actions/core")) => Promise | {tools: Array<{name: string, description?: string, inputSchema?: unknown}>, emptyWasSuccessful: boolean}>} [options.fetchFn] - Fetch function (injectable for tests) + * @returns {Promise>} + */ +async function fetchMCPToolsWithRetry(serverUrl, apiKey, serverName, core, { sleep = undefined, fetchFn = undefined } = {}) { + const doSleep = sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const doFetchResult = async (url, key, c) => { + if (!fetchFn) { + return fetchMCPToolsResult(url, key, c); + } + const result = await fetchFn(url, key, c); + if (Array.isArray(result)) { + return { tools: result, emptyWasSuccessful: true }; + } + return result; + }; + let result = await doFetchResult(serverUrl, apiKey, core); + for (let attempt = 1; attempt <= TOOLS_EMPTY_MAX_RETRIES && result.emptyWasSuccessful && result.tools.length === 0; attempt++) { + core.warning(` tools/list returned 0 tools for '${serverName}', retrying in ${TOOLS_EMPTY_RETRY_DELAY_MS}ms (attempt ${attempt}/${TOOLS_EMPTY_MAX_RETRIES})...`); + await doSleep(TOOLS_EMPTY_RETRY_DELAY_MS); + result = await doFetchResult(serverUrl, apiKey, core); + if (!result.emptyWasSuccessful) { + core.warning(` stopping empty tools/list retries for '${serverName}' because tools/list did not complete successfully`); + break; + } + } + if (result.emptyWasSuccessful && result.tools.length === 0) { + core.warning(` tools/list still returned 0 tools for '${serverName}' after ${TOOLS_EMPTY_MAX_RETRIES} retries; continuing with empty tool list`); } + return result.tools; } /** @@ -466,7 +543,7 @@ async function main() { fs.mkdirSync(CLI_BIN_DIR, { recursive: true }); fs.mkdirSync(TOOLS_DIR, { recursive: true }); } catch (err) { - throw new Error(`Failed to create MCP CLI directories: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create MCP CLI directories: ${getErrorMessage(err)}`, { cause: err }); } // The bridge script lives alongside mount_mcp_as_cli.cjs in the setup actions directory. @@ -521,8 +598,10 @@ async function main() { const toolsFile = path.join(TOOLS_DIR, `${name}.json`); - // Query tools from the server using the host-accessible URL (mount step runs on host) - let tools = await fetchMCPTools(url, apiKey, core); + // Query tools from the server using the host-accessible URL (mount step runs on host). + // Retries on empty to handle the race between gateway health-reporting and + // the backend finishing internal tool-schema construction (common with large configs). + let tools = await fetchMCPToolsWithRetry(url, apiKey, name, core); const validate = SERVER_VALIDATORS[name]; if (validate) { tools = validate(tools, core); @@ -582,6 +661,7 @@ module.exports = { AWF_GATEWAY_IP, main, fetchMCPTools, + fetchMCPToolsWithRetry, generateCLIWrapperScript, isValidServerName, shellEscapeDoubleQuoted, @@ -593,4 +673,6 @@ module.exports = { writeSafeOutputsGatewayEmptyFlag, SERVER_VALIDATORS, buildMCPCLIServersPromptList, + TOOLS_EMPTY_MAX_RETRIES, + TOOLS_EMPTY_RETRY_DELAY_MS, }; diff --git a/setup/js/parse_mcp_gateway_log.cjs b/setup/js/parse_mcp_gateway_log.cjs index 427ade7..1aa9abf 100644 --- a/setup/js/parse_mcp_gateway_log.cjs +++ b/setup/js/parse_mcp_gateway_log.cjs @@ -209,7 +209,7 @@ async function writeStepSummaryWithTokenUsage(coreObj) { try { content = fs.readFileSync(TOKEN_USAGE_PATH, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${TOKEN_USAGE_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${TOKEN_USAGE_PATH}: ${getErrorMessage(err)}`, { cause: err }); } if (content?.trim()) { coreObj.info(`Found token-usage.jsonl (${content.length} bytes)`); @@ -842,8 +842,11 @@ async function main() { difcFilteredEvents = parseGatewayJsonlForDifcFiltered(jsonlContent); tokenSteeringEvents = parseGatewayJsonlForTokenSteering(jsonlContent); modelAliasResolutionEvents = parseGatewayJsonlForModelAliasResolution(jsonlContent); - aiCreditsRateLimitError ||= hasAICreditsRateLimitError([jsonlContent]); - unknownModelAICredits ||= hasUnknownModelAICreditsError([jsonlContent]); + // Do NOT scan gateway.jsonl / rpc-messages.jsonl for AI credits rate limit errors. + // These files contain full MCP tool call request/response payloads including arbitrary + // repository data (branch names, commit messages, file contents) that can false-positively + // match the rate-limit patterns. Real AI credits rate limit errors from the inference API + // appear in gateway.log / stderr.log / gateway.md, not in MCP RPC message logs. if (difcFilteredEvents.length > 0) { core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in gateway.jsonl`); } @@ -862,8 +865,7 @@ async function main() { difcFilteredEvents = parseGatewayJsonlForDifcFiltered(rpcMessagesContent); tokenSteeringEvents = parseGatewayJsonlForTokenSteering(rpcMessagesContent); modelAliasResolutionEvents = parseGatewayJsonlForModelAliasResolution(rpcMessagesContent); - aiCreditsRateLimitError ||= hasAICreditsRateLimitError([rpcMessagesContent]); - unknownModelAICredits ||= hasUnknownModelAICreditsError([rpcMessagesContent]); + // Do NOT scan rpc-messages.jsonl for AI credits signals (same reason as gateway.jsonl above). if (difcFilteredEvents.length > 0) { core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in rpc-messages.jsonl`); } @@ -877,6 +879,29 @@ async function main() { core.info(`No gateway.jsonl or rpc-messages.jsonl found for steering or DIFC_FILTERED scanning`); } + // Always scan authoritative text logs for AI credits signals before selecting + // which format to render in the step summary. + let gatewayLogContent = ""; + let stderrLogContent = ""; + + if (fs.existsSync(gatewayLogPath)) { + gatewayLogContent = fs.readFileSync(gatewayLogPath, "utf8"); + core.info(`Found gateway.log (${gatewayLogContent.length} bytes)`); + aiCreditsRateLimitError ||= hasAICreditsRateLimitError([gatewayLogContent]); + unknownModelAICredits ||= hasUnknownModelAICreditsError([gatewayLogContent]); + } else { + core.info(`No gateway.log found at: ${gatewayLogPath}`); + } + + if (fs.existsSync(stderrLogPath)) { + stderrLogContent = fs.readFileSync(stderrLogPath, "utf8"); + core.info(`Found stderr.log (${stderrLogContent.length} bytes)`); + aiCreditsRateLimitError ||= hasAICreditsRateLimitError([stderrLogContent]); + unknownModelAICredits ||= hasUnknownModelAICreditsError([stderrLogContent]); + } else { + core.info(`No stderr.log found at: ${stderrLogPath}`); + } + // Try to read gateway.md if it exists (preferred for general gateway summary) if (fs.existsSync(gatewayMdPath)) { // MCPG pre-allocates a fixed-size header region in gateway.md that is never @@ -945,30 +970,7 @@ async function main() { return; } - // Fallback to legacy log files - let gatewayLogContent = ""; - let stderrLogContent = ""; - - // Read gateway.log if it exists - if (fs.existsSync(gatewayLogPath)) { - gatewayLogContent = fs.readFileSync(gatewayLogPath, "utf8"); - core.info(`Found gateway.log (${gatewayLogContent.length} bytes)`); - aiCreditsRateLimitError ||= hasAICreditsRateLimitError([gatewayLogContent]); - unknownModelAICredits ||= hasUnknownModelAICreditsError([gatewayLogContent]); - } else { - core.info(`No gateway.log found at: ${gatewayLogPath}`); - } - - // Read stderr.log if it exists - if (fs.existsSync(stderrLogPath)) { - stderrLogContent = fs.readFileSync(stderrLogPath, "utf8"); - core.info(`Found stderr.log (${stderrLogContent.length} bytes)`); - aiCreditsRateLimitError ||= hasAICreditsRateLimitError([stderrLogContent]); - unknownModelAICredits ||= hasUnknownModelAICreditsError([stderrLogContent]); - } else { - core.info(`No stderr.log found at: ${stderrLogPath}`); - } - + // Fallback to legacy log files for summary rendering. // If no legacy log content and no DIFC events, check if token usage is available if ( (!gatewayLogContent || gatewayLogContent.trim().length === 0) && @@ -1138,7 +1140,7 @@ if (typeof module !== "undefined" && module.exports) { // Run main if called directly if (require.main === module) { main().catch(err => { - console.error(err && err.stack ? err.stack : String(err)); + console.error(err && err.stack ? err.stack : getErrorMessage(err)); process.exitCode = 1; }); } diff --git a/setup/js/patch_awf_chroot_config.cjs b/setup/js/patch_awf_chroot_config.cjs index b215fc3..8f6e0a2 100644 --- a/setup/js/patch_awf_chroot_config.cjs +++ b/setup/js/patch_awf_chroot_config.cjs @@ -47,7 +47,7 @@ function patchAWFChrootConfig(options = {}) { fs.writeFileSync(configPath, output); fs.writeFileSync(artifactConfigPath, output); } catch (err) { - throw new Error(`Failed to write chroot config: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write chroot config: ${getErrorMessage(err)}`, { cause: err }); } return output; } diff --git a/setup/js/pick_experiment.cjs b/setup/js/pick_experiment.cjs index b425c28..cc57095 100644 --- a/setup/js/pick_experiment.cjs +++ b/setup/js/pick_experiment.cjs @@ -12,8 +12,8 @@ * or a new object with a 'variants' field and optional * metadata: weight, start_date, end_date, description, metric. * e.g. '{"feature1":["A","B"],"style":{"variants":["concise","detailed"],"weight":[70,30]}}' - * GH_AW_EXPERIMENT_STATE_FILE - Absolute path to the JSON state file to read/write - * e.g. /tmp/gh-aw/experiments/state.json + * GH_AW_EXPERIMENT_STATE_FILE - Absolute path to the experiment state file to read/write + * e.g. /tmp/gh-aw/experiments/state.jsonl * GH_AW_EXPERIMENT_STATE_DIR - Directory that holds the state file (created if missing) * e.g. /tmp/gh-aw/experiments * @@ -30,14 +30,17 @@ const fs = require("fs"); const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); -/** Maximum number of per-run records retained in state.runs. Older entries are pruned to keep state.json small. */ +/** Maximum number of per-run ledger records retained in state.runs for summaries. */ const MAX_RUN_HISTORY = 512; +const STATE_SOURCE_FORMAT = Symbol("experimentStateSourceFormat"); /** * @typedef {Object} ExperimentRunRecord * @property {string} run_id - GitHub Actions run ID (GITHUB_RUN_ID) * @property {string} timestamp - ISO-8601 UTC timestamp of the run * @property {Record} assignments - Maps experiment name → selected variant + * @property {Record>} [baseline_counts] + * Optional cumulative counts that existed before the recorded run history began. */ /** @@ -45,7 +48,7 @@ const MAX_RUN_HISTORY = 512; * @property {Record>} counts * Maps experiment name → variant → cumulative invocation count. * @property {ExperimentRunRecord[]} [runs] - * Per-run assignment history appended on each invocation. + * Per-run ledger history appended on each invocation. */ /** @@ -86,8 +89,69 @@ function normalizeConfig(raw) { return raw; } +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function mergeBaselineCounts(targetCounts, baselineCounts) { + if (!isPlainObject(baselineCounts)) { + throw new Error("Invalid baseline counts"); + } + for (const [name, variants] of Object.entries(baselineCounts)) { + if (!isPlainObject(variants)) { + throw new Error("Invalid baseline count variants"); + } + if (!targetCounts[name]) { + targetCounts[name] = {}; + } + for (const [variant, count] of Object.entries(variants)) { + if (!Number.isFinite(count)) { + throw new Error("Invalid baseline count value"); + } + targetCounts[name][variant] = (targetCounts[name][variant] || 0) + count; + } + } +} + +function deriveCountsFromRuns(runs) { + const counts = {}; + for (const run of runs) { + if (isPlainObject(run.baseline_counts)) { + mergeBaselineCounts(counts, run.baseline_counts); + } + for (const [name, variant] of Object.entries(run.assignments || {})) { + if (!counts[name]) { + counts[name] = {}; + } + counts[name][variant] = (counts[name][variant] || 0) + 1; + } + } + return counts; +} + +function diffBaselineCounts(totalCounts, representedCounts) { + const baselineCounts = {}; + for (const [name, variants] of Object.entries(totalCounts || {})) { + for (const [variant, count] of Object.entries(variants || {})) { + const represented = representedCounts[name]?.[variant] || 0; + const delta = count - represented; + if (delta > 0) { + if (!baselineCounts[name]) { + baselineCounts[name] = {}; + } + baselineCounts[name][variant] = delta; + } + } + } + return baselineCounts; +} + +function hasCounts(counts) { + return Object.values(counts).some(variants => Object.keys(variants).length > 0); +} + /** - * Load and parse the state JSON file. Returns an empty state if the file does not exist + * Load and parse the state file. Returns an empty state if the file does not exist * or cannot be parsed (e.g. first run or corrupted cache). * * @param {string} stateFile @@ -96,21 +160,66 @@ function normalizeConfig(raw) { function loadState(stateFile) { try { const raw = fs.readFileSync(stateFile, "utf8"); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed.counts === "object") { - if (!Array.isArray(parsed.runs)) { - parsed.runs = []; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.counts === "object") { + if (!Array.isArray(parsed.runs)) { + parsed.runs = []; + } + Object.defineProperty(parsed, STATE_SOURCE_FORMAT, { value: "json", configurable: true }); + return parsed; + } + } catch {} + + /** @type {{ counts: Record>, runs: ExperimentRunRecord[] }} */ + const state = { counts: {}, runs: [] }; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const entry = JSON.parse(trimmed); + if (entry && typeof entry.counts === "object") { + state.counts = entry.counts; + state.runs = Array.isArray(entry.runs) ? entry.runs.slice(-MAX_RUN_HISTORY) : []; + continue; + } + if (entry && typeof entry.run_id === "string" && typeof entry.timestamp === "string" && entry.assignments && typeof entry.assignments === "object" && !Array.isArray(entry.assignments)) { + if (entry.baseline_counts !== undefined) { + mergeBaselineCounts(state.counts, entry.baseline_counts); + } + state.runs.push(entry); + if (state.runs.length > MAX_RUN_HISTORY) { + state.runs = state.runs.slice(-MAX_RUN_HISTORY); + } + for (const [name, variant] of Object.entries(entry.assignments)) { + if (typeof variant !== "string") { + throw new Error("Invalid assignment variant"); + } + if (!state.counts[name]) { + state.counts[name] = {}; + } + state.counts[name][variant] = (state.counts[name][variant] || 0) + 1; + } + continue; } - return parsed; + throw new Error("Invalid experiment state record"); } - } catch { - // File missing, unreadable, or invalid JSON – start fresh. + Object.defineProperty(state, STATE_SOURCE_FORMAT, { value: "jsonl", configurable: true }); + return state; + } catch (err) { + // When state.jsonl is absent, fall back to state.json for cache-mode compatibility. + if (stateFile.endsWith(".jsonl") && err && /** @type {any} */ err.code === "ENOENT") { + const legacyFile = stateFile.replace(/\.jsonl$/, ".json"); + return loadState(legacyFile); + } + // File unreadable or invalid – start fresh. } return { counts: {}, runs: [] }; } /** - * Persist the state JSON file to disk. + * Persist the state file to disk. * * @param {string} stateFile * @param {ExperimentState} state @@ -119,9 +228,45 @@ function saveState(stateFile, state) { const dir = path.dirname(stateFile); try { fs.mkdirSync(dir, { recursive: true }); + if (stateFile.endsWith(".jsonl")) { + const runs = Array.isArray(state.runs) ? state.runs.map(run => JSON.parse(JSON.stringify(run))) : []; + if (runs.length === 0) { + fs.writeFileSync(stateFile, "", "utf8"); + return; + } + if (state[STATE_SOURCE_FORMAT] === "json") { + const baselineCounts = diffBaselineCounts(state.counts || {}, deriveCountsFromRuns(runs)); + if (hasCounts(baselineCounts)) { + runs[0].baseline_counts = baselineCounts; + } + fs.writeFileSync(stateFile, `${runs.map(run => JSON.stringify(run)).join("\n")}\n`, "utf8"); + Object.defineProperty(state, STATE_SOURCE_FORMAT, { value: "jsonl", configurable: true }); + return; + } + // Write all bounded runs (state.runs is already limited to MAX_RUN_HISTORY) using + // writeFileSync so the on-disk ledger never grows past MAX_RUN_HISTORY entries. + // Any cumulative counts from records that were trimmed by MAX_RUN_HISTORY slicing + // are preserved in runs[0].baseline_counts so no historical totals are lost. + const derivedCounts = deriveCountsFromRuns(runs); + const excess = diffBaselineCounts(state.counts || {}, derivedCounts); + if (hasCounts(excess)) { + const existingBaseline = isPlainObject(runs[0].baseline_counts) ? /** @type {Record>} */ runs[0].baseline_counts : {}; + /** @type {Record>} */ + const mergedBaseline = Object.assign({}, existingBaseline); + for (const [name, variants] of Object.entries(excess)) { + if (!mergedBaseline[name]) mergedBaseline[name] = {}; + for (const [variant, count] of Object.entries(variants)) { + mergedBaseline[name][variant] = (mergedBaseline[name][variant] || 0) + count; + } + } + runs[0] = Object.assign({}, runs[0], { baseline_counts: mergedBaseline }); + } + fs.writeFileSync(stateFile, `${runs.map(run => JSON.stringify(run)).join("\n")}\n`, "utf8"); + return; + } fs.writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n", "utf8"); } catch (err) { - throw new Error(`Failed to persist experiment state ${stateFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to persist experiment state ${stateFile}: ${getErrorMessage(err)}`, { cause: err }); } } @@ -362,7 +507,7 @@ async function writeSummary(assignments, configs, state, core) { */ async function main() { const specRaw = process.env.GH_AW_EXPERIMENT_SPEC || "{}"; - const stateFile = process.env.GH_AW_EXPERIMENT_STATE_FILE || "/tmp/gh-aw/experiments/state.json"; + const stateFile = process.env.GH_AW_EXPERIMENT_STATE_FILE || "/tmp/gh-aw/experiments/state.jsonl"; const stateDir = process.env.GH_AW_EXPERIMENT_STATE_DIR || "/tmp/gh-aw/experiments"; /** @type {Record} */ @@ -391,7 +536,7 @@ async function main() { try { fs.mkdirSync(stateDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${stateDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${stateDir}: ${getErrorMessage(err)}`, { cause: err }); } const state = loadState(stateFile); @@ -445,7 +590,7 @@ async function main() { state.runs = []; } state.runs.push({ run_id: runId, timestamp, assignments: { ...assignments } }); - // Prune run history to avoid state.json growing without bound over many runs. + // Prune in-memory run history so summaries stay small even when state.jsonl is append-only. if (state.runs.length > MAX_RUN_HISTORY) { state.runs = state.runs.slice(-MAX_RUN_HISTORY); } @@ -463,7 +608,7 @@ async function main() { try { fs.writeFileSync(assignmentsFile, JSON.stringify(assignments, null, 2) + "\n", "utf8"); } catch (err) { - throw new Error(`Failed to write file ${assignmentsFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${assignmentsFile}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Experiment assignments written to ${assignmentsFile}`); diff --git a/setup/js/push_experiment_state.cjs b/setup/js/push_experiment_state.cjs index f209403..c0a8260 100644 --- a/setup/js/push_experiment_state.cjs +++ b/setup/js/push_experiment_state.cjs @@ -16,7 +16,7 @@ * GH_AW_STATE_LABEL - Human-readable label used in logs/messages * * Backward-compatible experiment aliases: - * GH_AW_EXPERIMENT_STATE_DIR - Directory containing state.json / assignments.json + * GH_AW_EXPERIMENT_STATE_DIR - Directory containing state.jsonl/state.json and assignments.json * GH_AW_EXPERIMENT_BRANCH - Target git branch for experiment state * GH_TOKEN / GITHUB_TOKEN - GitHub token for API access and git operations * GITHUB_RUN_ID - Run ID used in commit messages @@ -31,6 +31,242 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs"); const { pushSignedCommits } = require("./push_signed_commits.cjs"); +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stableJSONStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJSONStringify).join(",")}]`; + } + if (isPlainObject(value)) { + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${stableJSONStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +/** Maximum number of run-ledger records to retain per state.jsonl file. Keeps the file well under the load limit. */ +const MAX_LEDGER_RECORDS = 512; + +function sortRunsByTimestamp(runs) { + return runs.slice().sort((a, b) => { + const ta = isPlainObject(a) && typeof a.timestamp === "string" ? a.timestamp : ""; + const tb = isPlainObject(b) && typeof b.timestamp === "string" ? b.timestamp : ""; + if (ta < tb) return -1; + if (ta > tb) return 1; + const ra = isPlainObject(a) && typeof a.run_id === "string" ? a.run_id : ""; + const rb = isPlainObject(b) && typeof b.run_id === "string" ? b.run_id : ""; + return ra < rb ? -1 : ra > rb ? 1 : 0; + }); +} + +function mergeExperimentRuns(remoteRuns, localRuns) { + const merged = []; + const seen = new Set(); + for (const run of [...remoteRuns, ...localRuns]) { + const key = + isPlainObject(run) && typeof run.run_id === "string" && typeof run.timestamp === "string" && isPlainObject(run.assignments) + ? `${run.run_id}\u0000${run.timestamp}\u0000${stableJSONStringify(run.assignments)}` + : stableJSONStringify(run); + if (!seen.has(key)) { + seen.add(key); + merged.push(run); + } + } + return sortRunsByTimestamp(merged); +} + +function mergeExperimentStateValue(baseValue, remoteValue, localValue) { + if (Number.isFinite(baseValue) && Number.isFinite(remoteValue) && Number.isFinite(localValue)) { + return remoteValue + localValue - baseValue; + } + if (stableJSONStringify(baseValue) === stableJSONStringify(remoteValue)) { + return localValue; + } + if (stableJSONStringify(baseValue) === stableJSONStringify(localValue)) { + return remoteValue; + } + if (Array.isArray(remoteValue) && Array.isArray(localValue)) { + return mergeExperimentRuns(remoteValue, localValue); + } + if (isPlainObject(remoteValue) && isPlainObject(localValue)) { + const result = {}; + for (const key of new Set([...Object.keys(baseValue || {}), ...Object.keys(remoteValue), ...Object.keys(localValue)])) { + result[key] = mergeExperimentStateValue(baseValue?.[key], remoteValue[key], localValue[key]); + } + return result; + } + if (stableJSONStringify(remoteValue) === stableJSONStringify(localValue)) { + return localValue; + } + return localValue; +} + +function mergeExperimentStateJSON(baseState, remoteState, localState) { + if (!isPlainObject(baseState) || !isPlainObject(remoteState) || !isPlainObject(localState)) { + throw new Error("Experiment state merge requires JSON objects"); + } + const merged = mergeExperimentStateValue(baseState, remoteState, localState); + if (!isPlainObject(merged) || !isPlainObject(merged.counts)) { + throw new Error("Merged experiment state is invalid"); + } + if (merged.runs !== undefined && !Array.isArray(merged.runs)) { + throw new Error("Merged experiment state runs must be an array when present"); + } + return merged; +} + +function mergeExperimentStateJSONL(remoteContent, localContent) { + const merged = []; + const seen = new Set(); + for (const content of [remoteContent, localContent]) { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + let entry; + try { + entry = JSON.parse(trimmed); + } catch { + // Skip lines that cannot be parsed as JSON. This makes the merge resilient + // to partial writes or corruption in either side of the conflict. + core.warning(`mergeExperimentStateJSONL: skipping unparseable line during conflict merge`); + continue; + } + const key = stableJSONStringify(entry); + if (!seen.has(key)) { + seen.add(key); + merged.push(entry); + } + } + } + + // Sort entries chronologically so the ledger is always in timestamp order. + merged.sort((a, b) => { + const ta = isPlainObject(a) && typeof a.timestamp === "string" ? a.timestamp : ""; + const tb = isPlainObject(b) && typeof b.timestamp === "string" ? b.timestamp : ""; + if (ta < tb) return -1; + if (ta > tb) return 1; + const ra = isPlainObject(a) && typeof a.run_id === "string" ? a.run_id : ""; + const rb = isPlainObject(b) && typeof b.run_id === "string" ? b.run_id : ""; + return ra < rb ? -1 : ra > rb ? 1 : 0; + }); + + // Compact the ledger to avoid exceeding the loader file-size limit. + // Counts from pruned records are folded into the first remaining entry's baseline_counts + // so cumulative totals are preserved across compaction boundaries. + if (merged.length > MAX_LEDGER_RECORDS) { + const pruned = merged.splice(0, merged.length - MAX_LEDGER_RECORDS); + if (merged.length > 0) { + /** @type {Record>} */ + const baseline = {}; + for (const entry of pruned) { + if (!isPlainObject(entry)) continue; + if (isPlainObject(entry.baseline_counts)) { + for (const [name, variants] of Object.entries(entry.baseline_counts)) { + if (!baseline[name]) baseline[name] = {}; + for (const [variant, count] of Object.entries(/** @type {Record} */ variants)) { + baseline[name][variant] = (baseline[name][variant] || 0) + (typeof count === "number" ? count : 0); + } + } + } + if (isPlainObject(entry.assignments)) { + for (const [name, variant] of Object.entries(entry.assignments)) { + if (typeof variant !== "string") continue; + if (!baseline[name]) baseline[name] = {}; + baseline[name][variant] = (baseline[name][variant] || 0) + 1; + } + } + } + if (Object.keys(baseline).length > 0) { + const first = merged[0]; + const existing = isPlainObject(first) && isPlainObject(first.baseline_counts) ? first.baseline_counts : {}; + /** @type {Record>} */ + const mergedBaseline = Object.assign({}, /** @type {Record>} */ existing); + for (const [name, variants] of Object.entries(baseline)) { + if (!mergedBaseline[name]) mergedBaseline[name] = {}; + for (const [variant, count] of Object.entries(variants)) { + mergedBaseline[name][variant] = (mergedBaseline[name][variant] || 0) + count; + } + } + merged[0] = Object.assign({}, first, { baseline_counts: mergedBaseline }); + } + } + } + + return merged.length > 0 ? `${merged.map(entry => JSON.stringify(entry)).join("\n")}\n` : ""; +} + +function readGitStageFile(workspaceDir, stage, filePath) { + return execGitSync(["show", `:${stage}:${filePath}`], { + cwd: workspaceDir, + stdio: "pipe", + suppressLogs: true, + }); +} + +function resolveExperimentStateRebaseConflict({ cwd }) { + const conflictedFiles = execGitSync(["diff", "--name-only", "--diff-filter=U"], { + cwd, + stdio: "pipe", + suppressLogs: true, + }) + .trim() + .split("\n") + .map(file => file.trim()) + .filter(Boolean); + + if (conflictedFiles.length === 0 || (!conflictedFiles.includes("state.json") && !conflictedFiles.includes("state.jsonl"))) { + return false; + } + + const allowedConflicts = new Set(["state.json", "state.jsonl", "assignments.json"]); + for (const file of conflictedFiles) { + if (!allowedConflicts.has(file)) { + return false; + } + } + + if (conflictedFiles.includes("state.json")) { + try { + const baseState = JSON.parse(readGitStageFile(cwd, 1, "state.json")); + const remoteState = JSON.parse(readGitStageFile(cwd, 2, "state.json")); + const localState = JSON.parse(readGitStageFile(cwd, 3, "state.json")); + const mergedState = mergeExperimentStateJSON(baseState, remoteState, localState); + fs.writeFileSync(path.join(cwd, "state.json"), JSON.stringify(mergedState, null, 2) + "\n", "utf8"); + } catch (err) { + throw new Error(`Failed to resolve state.json rebase conflict: ${getErrorMessage(err)}`, { cause: err }); + } + } + + if (conflictedFiles.includes("state.jsonl")) { + try { + const remoteState = readGitStageFile(cwd, 2, "state.jsonl"); + const localState = readGitStageFile(cwd, 3, "state.jsonl"); + const mergedState = mergeExperimentStateJSONL(remoteState, localState); + fs.writeFileSync(path.join(cwd, "state.jsonl"), mergedState, "utf8"); + } catch (err) { + throw new Error(`Failed to resolve state.jsonl rebase conflict: ${getErrorMessage(err)}`, { cause: err }); + } + } + + if (conflictedFiles.includes("assignments.json")) { + try { + const localAssignments = readGitStageFile(cwd, 3, "assignments.json"); + fs.writeFileSync(path.join(cwd, "assignments.json"), localAssignments, "utf8"); + } catch (err) { + throw new Error(`Failed to resolve assignments.json rebase conflict: ${getErrorMessage(err)}`, { cause: err }); + } + } + + execGitSync(["add", "--", ...conflictedFiles], { stdio: "inherit", cwd }); + return true; +} + /** * Checkout or create an orphan git branch for experiment state. * Returns the remote HEAD SHA (empty string for a new branch). @@ -65,7 +301,11 @@ function checkoutOrCreateBranch(branchName, repoUrl, workspaceDir) { } for (const entry of entries) { if (entry !== ".git") { - fs.rmSync(path.join(workspaceDir, entry), { recursive: true, force: true }); + try { + fs.rmSync(path.join(workspaceDir, entry), { recursive: true, force: true }); + } catch (err) { + throw new Error(`Failed to remove workspace entry ${entry}: ${getErrorMessage(err)}`, { cause: err }); + } } } return ""; @@ -79,7 +319,7 @@ async function main() { const stateDir = process.env.GH_AW_STATE_DIR || process.env.GH_AW_EXPERIMENT_STATE_DIR || "/tmp/gh-aw/experiments"; const branchName = process.env.GH_AW_STATE_BRANCH || process.env.GH_AW_EXPERIMENT_BRANCH || ""; const stateLabel = process.env.GH_AW_STATE_LABEL || "experiment state"; - const filesEnv = process.env.GH_AW_STATE_FILES || "state.json,assignments.json"; + const filesEnv = process.env.GH_AW_STATE_FILES || "state.jsonl,state.json,assignments.json"; const candidateFiles = filesEnv .split(",") .map(name => name.trim()) @@ -214,6 +454,7 @@ async function main() { baseRef: currentBaseRef, cwd: workspaceDir, gitAuthEnv: getGitAuthEnv(ghToken), + resolveRebaseConflict: resolveExperimentStateRebaseConflict, }); core.info(`Successfully pushed ${stateLabel} to ${branchName}`); return; @@ -251,4 +492,4 @@ async function main() { } } -module.exports = { main, checkoutOrCreateBranch }; +module.exports = { main, checkoutOrCreateBranch, mergeExperimentStateJSON, mergeExperimentStateJSONL, mergeExperimentRuns, resolveExperimentStateRebaseConflict }; diff --git a/setup/js/push_repo_memory.cjs b/setup/js/push_repo_memory.cjs index 45ca127..fe83b37 100644 --- a/setup/js/push_repo_memory.cjs +++ b/setup/js/push_repo_memory.cjs @@ -89,7 +89,7 @@ async function main() { try { raw = fs.readFileSync(absPath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${absPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${absPath}: ${getErrorMessage(err)}`, { cause: err }); } if (!raw.trim()) { throw new Error(`Empty JSON file: ${absPath}`); diff --git a/setup/js/push_signed_commits.cjs b/setup/js/push_signed_commits.cjs index 69c40f2..f8cd31b 100644 --- a/setup/js/push_signed_commits.cjs +++ b/setup/js/push_signed_commits.cjs @@ -352,9 +352,26 @@ async function resolveLocalHeadSha(cwd) { * @param {Record} [opts.resolvedTemporaryIds] - Resolved temporary IDs map * @param {string} [opts.currentRepo] - Repository slug used for same-repo temporary ID resolution * @param {Record} [opts.validationConfig] - Optional safe-output policy config applied to synthesized GraphQL fileChanges + * @param {(context: {cwd: string, branch: string, output: string}) => (boolean|Promise)} [opts.resolveRebaseConflict] * @returns {Promise} SHA of the commit that landed on the target branch */ -async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, pushRemoteUrl, pushToken, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo, validationConfig }) { +async function pushSignedCommits({ + githubClient, + owner, + repo, + branch, + baseRef, + cwd, + gitAuthEnv, + pushRemoteUrl, + pushToken, + signedCommits = true, + allowGitPushFallback = true, + resolvedTemporaryIds, + currentRepo, + validationConfig, + resolveRebaseConflict, +}) { const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`; const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, { defaultRepo: effectiveCurrentRepo, @@ -476,17 +493,40 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c let rebaseResult = await runRebase(); if (rebaseResult.exitCode !== 0) { const combinedOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; - // Always abort the in-progress rebase before attempting any recovery. - try { - await exec.exec("git", ["rebase", "--abort"], { cwd }); - } catch { - // Ignore cleanup failures. + let rebaseResolved = false; + if (!isPartialCloneObjectFailure(combinedOutput) && typeof resolveRebaseConflict === "function") { + try { + rebaseResolved = await resolveRebaseConflict({ cwd, branch, output: combinedOutput }); + } catch (resolveError) { + core.warning(`pushSignedCommits: custom rebase conflict resolver failed: ${getErrorMessage(resolveError)}`); + } } - // Recovery: if the failure was caused by missing objects in a shallow/ - // partial clone, backfill the exact commit objects this rebase needs and - // retry once. - if (isPartialCloneObjectFailure(combinedOutput)) { + if (rebaseResolved) { + rebaseResult = await exec.getExecOutput("git", ["rebase", "--continue"], { + cwd, + env: { ...process.env, ...(gitAuthEnv || {}), GIT_EDITOR: "true" }, + ignoreReturnCode: true, + }); + if (rebaseResult.exitCode !== 0) { + try { + await exec.exec("git", ["rebase", "--abort"], { cwd }); + } catch { + // Ignore cleanup failures. + } + const continueOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; + throw new Error(`pushSignedCommits: resolved a rebase conflict for branch '${branch}' but could not continue the rebase. ` + `Root cause: ${continueOutput.trim()}`); + } + } else if (isPartialCloneObjectFailure(combinedOutput)) { + // Always abort the in-progress rebase before attempting recovery. + try { + await exec.exec("git", ["rebase", "--abort"], { cwd }); + } catch { + // Ignore cleanup failures. + } + // Recovery: if the failure was caused by missing objects in a shallow/ + // partial clone, backfill the exact commit objects this rebase needs and + // retry once. // Backfill the full object content (trees + blobs) of EXACTLY the // commits this rebase touches: the new GraphQL parent, the old replay // parent, and the current branch tip. Fetching these anchor commits @@ -531,6 +571,11 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c ); } } else { + try { + await exec.exec("git", ["rebase", "--abort"], { cwd }); + } catch { + // Ignore cleanup failures. + } throw new Error(`pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}`); } } diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs index e29228d..4f797ed 100644 --- a/setup/js/push_to_pull_request_branch.cjs +++ b/setup/js/push_to_pull_request_branch.cjs @@ -21,7 +21,7 @@ const { withGitHubHostToken } = require("./git_auth_helpers.cjs"); const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); -const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs"); +const { getThreatWarningPresentation } = require("./threat_detection_warning.cjs"); const { attachExecutionState } = require("./safe_output_execution_metadata.cjs"); const { resolveTransportPaths } = require("./resolve_transport_paths.cjs"); @@ -414,7 +414,7 @@ async function main(config = {}) { try { patchContent = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } // Check for actual error conditions @@ -552,7 +552,7 @@ async function main(config = {}) { try { patchStats = fs.readFileSync(patchFilePath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${patchFilePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${patchFilePath}: ${getErrorMessage(err)}`, { cause: err }); } if (patchStats.trim()) { content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; @@ -1304,11 +1304,12 @@ async function main(config = {}) { // For fork-backed PRs, use an owner-qualified head reference. const reviewHeadRef = pushRemoteUrl ? `${pushRepoParts.owner}:${reviewBranchName}` : reviewBranchName; const detectionReasonEnv = process.env.GH_AW_DETECTION_REASON || "unknown"; + const warning = getThreatWarningPresentation(detectionReasonEnv); const prBody = [ - "> [!CAUTION]", - "> agentic threat detected", - "> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.", - `> ${getThreatDetectedMarker(detectionReasonEnv)}`, + `> [!${warning.admonition}]`, + `> ${warning.title}`, + `> ${warning.summary}`, + `> ${warning.marker}`, ">", `> **Reason:** ${detectionReasonEnv}`, ">", diff --git a/setup/js/resolve_pr_review_thread.cjs b/setup/js/resolve_pr_review_thread.cjs index 2f630ee..ae508ee 100644 --- a/setup/js/resolve_pr_review_thread.cjs +++ b/setup/js/resolve_pr_review_thread.cjs @@ -22,13 +22,19 @@ const HANDLER_TYPE = "resolve_pull_request_review_thread"; * Used to validate the thread before resolving. * @param {any} github - GitHub GraphQL instance * @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...') - * @returns {Promise<{prNumber: number, repoNameWithOwner: string|null}|null>} The PR number and repo, or null if not found + * @returns {Promise< + * | {status: "missing"} + * | {status: "thread", prNumber: number, repoNameWithOwner: string|null, isResolved: boolean} + * | {status: "invalid_type", nodeType: string} + * >} Thread lookup result */ async function getThreadPullRequestInfo(github, threadId) { const query = /* GraphQL */ ` query ($threadId: ID!) { node(id: $threadId) { + __typename ... on PullRequestReviewThread { + isResolved pullRequest { number repository { @@ -42,14 +48,23 @@ async function getThreadPullRequestInfo(github, threadId) { const result = await github.graphql(query, { threadId }); - const pullRequest = result?.node?.pullRequest; - if (!pullRequest) { - return null; + const threadNode = result?.node; + if (!threadNode) { + return { status: "missing" }; + } + if (threadNode.__typename !== "PullRequestReviewThread") { + return { + status: "invalid_type", + nodeType: threadNode.__typename || "unknown", + }; } + const pullRequest = threadNode.pullRequest; return { + status: "thread", prNumber: pullRequest.number, repoNameWithOwner: pullRequest.repository?.nameWithOwner ?? null, + isResolved: threadNode?.isResolved === true, }; } @@ -174,15 +189,24 @@ async function main(config = {}) { // Look up the thread's PR number and repository const threadInfo = await getThreadPullRequestInfo(githubClient, threadId); - if (threadInfo === null) { - core.warning(`Review thread not found or not a PullRequestReviewThread: ${threadId}`); + if (threadInfo.status === "missing") { + core.info(`Review thread ${threadId} not found — already resolved or stale; skipping`); + return { + success: true, + thread_id: threadId, + is_resolved: true, + skipped: true, + }; + } + + if (threadInfo.status !== "thread") { return { success: false, - error: `Review thread not found: ${threadId}`, + error: `thread_id must reference a PullRequestReviewThread node ID (PRRT_...); received ${threadInfo.nodeType} for ${threadId}`, }; } - const { prNumber: threadPRNumber, repoNameWithOwner: threadRepo } = threadInfo; + const { prNumber: threadPRNumber, repoNameWithOwner: threadRepo, isResolved } = threadInfo; // When the user explicitly configured target-repo or allowed-repos, validate the thread's // repository using validateTargetRepo (supports wildcards like "*", "org/*"). @@ -282,6 +306,16 @@ async function main(config = {}) { const filterResult = await checkRequiredFilter(githubClient, repoParts, threadPRNumber, requiredLabels, requiredTitlePrefix, "resolve_pull_request_review_thread"); if (filterResult) return filterResult; + if (isResolved) { + core.info(`Review thread ${threadId} is already resolved; skipping`); + return { + success: true, + thread_id: threadId, + is_resolved: true, + skipped: true, + }; + } + // If in staged mode, preview without executing if (isStaged) { logStagedPreviewInfo(`Would resolve review thread ${threadId}`); diff --git a/setup/js/route_slash_command.cjs b/setup/js/route_slash_command.cjs index 4905786..a1591b5 100644 --- a/setup/js/route_slash_command.cjs +++ b/setup/js/route_slash_command.cjs @@ -38,7 +38,7 @@ async function appendRoutingSummary(existingCommands, selectedCommand) { } await summary.write({ overwrite: false }); } catch (error) { - core.warning(`Failed to write centralized routing details to step summary: ${String(error)}`); + core.warning(`Failed to write centralized routing details to step summary: ${getErrorMessage(error)}`); } } @@ -103,7 +103,7 @@ async function resolveIssueBackedPRHeadRef() { } return normalizeDispatchRef(headRef); } catch (error) { - core.warning(`Failed to resolve PR head ref for #${pullNumber}: ${String(error)}`); + core.warning(`Failed to resolve PR head ref for #${pullNumber}: ${getErrorMessage(error)}`); return ""; } } @@ -298,7 +298,7 @@ async function addImmediateReaction(reaction) { return; } } catch (error) { - core.warning(`Immediate reaction '${normalized}' failed: ${String(error)}`); + core.warning(`Immediate reaction '${normalized}' failed: ${getErrorMessage(error)}`); } } @@ -317,7 +317,7 @@ async function addImmediateStatusComment() { ...(comment.repo?.owner && comment.repo?.repo ? { status_comment_repo: `${comment.repo.owner}/${comment.repo.repo}` } : {}), }; } catch (error) { - core.warning(`Immediate status comment failed: ${String(error)}`); + core.warning(`Immediate status comment failed: ${getErrorMessage(error)}`); return null; } } @@ -398,7 +398,7 @@ async function dispatchWorkflow(workflowId, ref, inputs) { core.info(`Skipping workflow '${workflowId}' because it is disabled.`); return { dispatched: false }; } - throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`, { cause: error }); + throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${getErrorMessage(error)}`, { cause: error }); } } @@ -434,7 +434,7 @@ async function updateStatusCommentWithDispatch(statusCommentContext, eventName, nonFatalStatusCommentErrors: true, }); } catch (error) { - core.warning(`Failed to update immediate status comment with dispatched run details: ${String(error)}`); + core.warning(`Failed to update immediate status comment with dispatched run details: ${getErrorMessage(error)}`); } } @@ -477,7 +477,7 @@ function parseHelpCommandsMetadata() { }) .sort((left, right) => left.command.localeCompare(right.command)); } catch (error) { - core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${getErrorMessage(error)}`); return []; } } @@ -600,7 +600,7 @@ async function postBuiltinHelpComment(commentBody) { core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); return false; } catch (error) { - core.warning(`Failed to post builtin /help comment: ${String(error)}`); + core.warning(`Failed to post builtin /help comment: ${getErrorMessage(error)}`); return false; } } diff --git a/setup/js/run_evals.cjs b/setup/js/run_evals.cjs index a1a4afb..decc7e0 100644 --- a/setup/js/run_evals.cjs +++ b/setup/js/run_evals.cjs @@ -75,7 +75,7 @@ async function setupMain() { try { fs.mkdirSync(EVALS_DIR, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${EVALS_DIR}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${EVALS_DIR}: ${getErrorMessage(err)}`, { cause: err }); } // Load agent output for evaluation context @@ -91,7 +91,7 @@ async function setupMain() { try { agentOutputContent = fs.readFileSync(agentOutputPath, "utf-8"); } catch (err) { - throw new Error(`Failed to read file ${agentOutputPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${agentOutputPath}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Agent output loaded: ${agentOutputPath} (${stats.size} bytes)`); } else { @@ -104,7 +104,7 @@ async function setupMain() { fs.mkdirSync("/tmp/gh-aw/aw-prompts", { recursive: true }); fs.writeFileSync("/tmp/gh-aw/aw-prompts/prompt.txt", prompt); } catch (err) { - throw new Error(`Failed to prepare eval prompt file: ${String(err)}`, { cause: err }); + throw new Error(`Failed to prepare eval prompt file: ${getErrorMessage(err)}`, { cause: err }); } core.exportVariable("GH_AW_PROMPT", "/tmp/gh-aw/aw-prompts/prompt.txt"); @@ -143,7 +143,7 @@ async function parseMain() { try { fs.writeFileSync(EVALS_OUTPUT_PATH, ""); } catch (err) { - throw new Error(`Failed to write file ${EVALS_OUTPUT_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${EVALS_OUTPUT_PATH}: ${getErrorMessage(err)}`, { cause: err }); } return; } @@ -152,7 +152,7 @@ async function parseMain() { try { logContent = fs.readFileSync(EVALS_LOG_PATH, "utf-8"); } catch (err) { - throw new Error(`Failed to read file ${EVALS_LOG_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${EVALS_LOG_PATH}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`Parsing evals log: ${EVALS_LOG_PATH} (${logContent.length} bytes)`); @@ -195,7 +195,7 @@ async function parseMain() { try { fs.writeFileSync(EVALS_OUTPUT_PATH, jsonlLines.join("\n") + (jsonlLines.length > 0 ? "\n" : "")); } catch (err) { - throw new Error(`Failed to write file ${EVALS_OUTPUT_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${EVALS_OUTPUT_PATH}: ${getErrorMessage(err)}`, { cause: err }); } core.info(`BinEval results written to ${EVALS_OUTPUT_PATH} (${results.length} record(s))`); // Step summary rendering is handled by the dedicated render_evals_summary.cjs step diff --git a/setup/js/runtime_import.cjs b/setup/js/runtime_import.cjs index 525b0da..4303595 100644 --- a/setup/js/runtime_import.cjs +++ b/setup/js/runtime_import.cjs @@ -668,7 +668,7 @@ async function fetchUrlContent(url, cacheDir) { try { fs.mkdirSync(cacheDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${cacheDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${cacheDir}: ${getErrorMessage(err)}`, { cause: err }); } } @@ -695,7 +695,7 @@ async function fetchUrlContent(url, cacheDir) { try { return fs.readFileSync(cacheFile, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${cacheFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${cacheFile}: ${getErrorMessage(err)}`, { cause: err }); } } } @@ -724,7 +724,7 @@ async function fetchUrlContent(url, cacheDir) { try { fs.writeFileSync(cacheFile, data, "utf8"); } catch (err) { - reject(new Error(`Failed to write file ${cacheFile}: ${String(err)}`, { cause: err })); + reject(new Error(`Failed to write file ${cacheFile}: ${getErrorMessage(err)}`, { cause: err })); return; } resolve(data); @@ -1061,7 +1061,7 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start try { content = fs.readFileSync(normalizedPath, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${normalizedPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${normalizedPath}: ${getErrorMessage(err)}`, { cause: err }); } // If line range is specified, extract those lines first (before other processing) diff --git a/setup/js/safe_outputs_config.cjs b/setup/js/safe_outputs_config.cjs index 1668d61..cac5138 100644 --- a/setup/js/safe_outputs_config.cjs +++ b/setup/js/safe_outputs_config.cjs @@ -107,7 +107,7 @@ function loadConfig(server) { try { fs.mkdirSync(outputDir, { recursive: true }); } catch (err) { - throw new Error(`${ERR_SYSTEM}: Failed to create directory ${outputDir}: ${String(err)}`, { cause: err }); + throw new Error(`${ERR_SYSTEM}: Failed to create directory ${outputDir}: ${getErrorMessage(err)}`, { cause: err }); } } diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index 171c57e..4efabe0 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -120,7 +120,7 @@ function hasExplicitTargetParameter(entry, fieldNames) { /** * @param {string} toolName - * @returns {{primary?: string, anyOf?: string[]} | null} + * @returns {{primary?: string, anyOf?: string[], allOf?: string[]} | null} */ function getWildcardTargetRequirement(toolName) { return safeOutputsToolMap.get(toolName)?.["x-safe-outputs-target-requirements"]?.["*"] || null; @@ -315,15 +315,23 @@ function createHandlers(server, appendSafeOutput, config = {}) { return null; } + const configKey = toolName.replace(/_/g, "-"); + const anyOf = Array.isArray(requirement.anyOf) ? requirement.anyOf : []; - if (anyOf.length === 0 || hasExplicitTargetParameter(entry, anyOf)) { - return null; + if (anyOf.length > 0 && !hasExplicitTargetParameter(entry, anyOf)) { + const primary = requirement.primary || anyOf[0]; + const guidance = anyOf.length === 1 ? primary : `one of: ${anyOf.join(", ")}`; + return buildIntentErrorResponse(`${toolName} requires ${primary} when safe-outputs.${configKey}.target is '*'. Provide ${guidance} and retry.`); } - const configKey = toolName.replace(/_/g, "-"); - const primary = requirement.primary || anyOf[0]; - const guidance = anyOf.length === 1 ? primary : `one of: ${anyOf.join(", ")}`; - return buildIntentErrorResponse(`${toolName} requires ${primary} when safe-outputs.${configKey}.target is '*'. Provide ${guidance} and retry.`); + const allOf = Array.isArray(requirement.allOf) ? requirement.allOf : []; + for (const field of allOf) { + if (!hasExplicitTargetParameter(entry, [field])) { + return buildIntentErrorResponse(`${toolName} requires ${field} when safe-outputs.${configKey}.target is '*'. Provide ${field} and retry.`); + } + } + + return null; }; /** @@ -500,7 +508,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { try { fs.mkdirSync(assetsDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${assetsDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${assetsDir}: ${getErrorMessage(err)}`, { cause: err }); } } @@ -509,7 +517,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { try { fileContent = fs.readFileSync(filePath); } catch (err) { - throw new Error(`Failed to read file ${filePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } const sha = crypto.createHash("sha256").update(fileContent).digest("hex"); @@ -2096,7 +2104,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { try { fs.mkdirSync(destDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${destDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${destDir}: ${getErrorMessage(err)}`, { cause: err }); } } let entries; @@ -2169,7 +2177,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { try { fs.mkdirSync(stagingDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${stagingDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${stagingDir}: ${getErrorMessage(err)}`, { cause: err }); } } diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 90d3ad8..03b10fe 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -1193,7 +1193,7 @@ }, { "name": "push_to_pull_request_branch", - "description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. IMPORTANT: always supply the 'branch' argument with the local branch name you committed to. In batch workflows that process multiple PRs, this is required \u2014 if omitted, the branch is inferred from the current git HEAD, which will produce wrong results if the workspace has been checked out to a different branch between commit and tool-call time. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite. This tool auto-pins the PR branch HEAD before pushing, so it takes no concurrency-control / compare-and-swap parameter \u2014 do NOT pass expected_head_sha, head_sha, or base_sha; the only accepted fields are message, branch, and pull_request_number.", + "description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. IMPORTANT: always supply the 'branch' argument with the local branch name you committed to. In batch workflows that process multiple PRs, this is required \u2014 if omitted, the branch is inferred from the current git HEAD, which will produce wrong results if the workspace has been checked out to a different branch between commit and tool-call time. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite. This tool auto-pins the PR branch HEAD before pushing, so it takes no concurrency-control / compare-and-swap parameter \u2014 do NOT pass expected_head_sha, head_sha, or base_sha; the only accepted fields are message, branch, pull_request_number, and repo.", "inputSchema": { "type": "object", "required": ["message"], @@ -1210,9 +1210,13 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to push changes to. This is the numeric ID from the GitHub URL (e.g., 654 in github.com/owner/repo/pull/654). Required when the workflow target is '*' (any PR).", + "description": "Pull request number to push changes to. This is the numeric ID from the GitHub URL (e.g., 654 in github.com/owner/repo/pull/654). Required when the workflow target is '*' (any PR) — both pull_request_number and repo must be supplied together for wildcard targets.", "x-synonyms": ["pullRequestNumber"] }, + "repo": { + "type": "string", + "description": "Target repository in 'owner/repo' format. For multi-repo workflows where the pull request may live in a side checkout, provide this explicitly so the correct repository checkout is selected." + }, "secrecy": { "type": "string", "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")." @@ -1226,8 +1230,9 @@ }, "x-safe-outputs-target-requirements": { "*": { - "primary": "pull_request_number", - "anyOf": ["pull_request_number"] + "primary": "repo", + "anyOf": ["repo"], + "allOf": ["pull_request_number"] } } }, diff --git a/setup/js/safe_outputs_tools_loader.cjs b/setup/js/safe_outputs_tools_loader.cjs index fa712be..40cf101 100644 --- a/setup/js/safe_outputs_tools_loader.cjs +++ b/setup/js/safe_outputs_tools_loader.cjs @@ -360,7 +360,7 @@ function registerDynamicTools(server, tools, config, outputFile, registerTool, n try { fs.appendFileSync(outputFile, `${JSON.stringify(entry)}\n`); } catch (err) { - throw new Error(`Failed to append to file ${outputFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to append to file ${outputFile}: ${getErrorMessage(err)}`, { cause: err }); } // Use output from safe-job config if available diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index 8331649..edb6a0c 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -873,6 +873,8 @@ const MAX_ATTR_VALUE_LENGTH = 1024; */ const REDACTED = "[REDACTED]"; +const FETCH_TIMEOUT_MS = 120_000; + /** * Sanitize an array of OTLP key-value attributes in-place (shallowly cloned). * @@ -1097,6 +1099,7 @@ async function sendOTLPSpan(endpoint, payload, { maxRetries = 2, baseDelayMs = 1 method: "POST", headers, body: sanitizedBody, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (response.ok) { return; diff --git a/setup/js/setup_comment_memory_files.cjs b/setup/js/setup_comment_memory_files.cjs index 96505e0..dfc3f9e 100644 --- a/setup/js/setup_comment_memory_files.cjs +++ b/setup/js/setup_comment_memory_files.cjs @@ -144,7 +144,7 @@ async function collectCommentMemoryFiles(githubClient, commentMemoryConfig) { try { fs.mkdirSync(COMMENT_MEMORY_DIR, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${COMMENT_MEMORY_DIR}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${COMMENT_MEMORY_DIR}: ${getErrorMessage(err)}`, { cause: err }); } let totalBytes = 0; for (const [memoryId, content] of memoryMap.entries()) { @@ -165,7 +165,7 @@ async function collectCommentMemoryFiles(githubClient, commentMemoryConfig) { try { fs.writeFileSync(filePath, `${content}\n`); } catch (err) { - throw new Error(`Failed to write file ${filePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${filePath}: ${getErrorMessage(err)}`, { cause: err }); } writtenFiles.push(filePath); } @@ -195,7 +195,7 @@ ${COMMENT_MEMORY_PROMPT_END_MARKER}`; try { promptContent = fs.readFileSync(PROMPT_PATH, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${PROMPT_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${PROMPT_PATH}: ${getErrorMessage(err)}`, { cause: err }); } const start = promptContent.indexOf(COMMENT_MEMORY_PROMPT_START_MARKER); const end = promptContent.indexOf(COMMENT_MEMORY_PROMPT_END_MARKER); @@ -208,7 +208,7 @@ ${COMMENT_MEMORY_PROMPT_END_MARKER}`; try { fs.writeFileSync(PROMPT_PATH, promptContent); } catch (err) { - throw new Error(`Failed to write file ${PROMPT_PATH}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to write file ${PROMPT_PATH}: ${getErrorMessage(err)}`, { cause: err }); } core.info("comment_memory setup: injected comment-memory prompt guidance"); } diff --git a/setup/js/setup_threat_detection.cjs b/setup/js/setup_threat_detection.cjs index 336844d..3e13b40 100644 --- a/setup/js/setup_threat_detection.cjs +++ b/setup/js/setup_threat_detection.cjs @@ -37,7 +37,7 @@ async function main() { try { templateContent = fs.readFileSync(templatePath, "utf-8"); } catch (err) { - throw new Error(`Failed to read file ${templatePath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${templatePath}: ${getErrorMessage(err)}`, { cause: err }); } // Check if prompt file exists (soft check; detection can continue with fallback context) // The agent artifact is downloaded to /tmp/gh-aw/threat-detection/ @@ -173,7 +173,7 @@ async function main() { fs.mkdirSync("/tmp/gh-aw/aw-prompts", { recursive: true }); fs.writeFileSync("/tmp/gh-aw/aw-prompts/prompt.txt", promptContent); } catch (err) { - throw new Error(`Failed to prepare threat detection prompt file: ${String(err)}`, { cause: err }); + throw new Error(`Failed to prepare threat detection prompt file: ${getErrorMessage(err)}`, { cause: err }); } core.exportVariable("GH_AW_PROMPT", "/tmp/gh-aw/aw-prompts/prompt.txt"); diff --git a/setup/js/start_mcp_gateway.cjs b/setup/js/start_mcp_gateway.cjs index 1b522a7..f3f4fcf 100644 --- a/setup/js/start_mcp_gateway.cjs +++ b/setup/js/start_mcp_gateway.cjs @@ -377,7 +377,7 @@ async function main() { try { fs.mkdirSync("/tmp/gh-aw/mcp-logs", { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory /tmp/gh-aw/mcp-logs: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory /tmp/gh-aw/mcp-logs: ${getErrorMessage(err)}`, { cause: err }); } // Symlink attack prevention on the config directory @@ -387,7 +387,7 @@ async function main() { try { fs.mkdirSync(configDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${configDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${configDir}: ${getErrorMessage(err)}`, { cause: err }); } // Post-creation check if (!assertNotSymlink(configDir)) { @@ -396,7 +396,7 @@ async function main() { try { fs.chmodSync(configDir, 0o700); } catch (err) { - throw new Error(`Failed to set permissions on ${configDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to set permissions on ${configDir}: ${getErrorMessage(err)}`, { cause: err }); } // ----------------------------------------------------------------------- @@ -432,7 +432,7 @@ async function main() { try { mcpConfig = fs.readFileSync(0, "utf8"); // fd 0 = stdin } catch (err) { - throw new Error(`Failed to read MCP configuration from stdin: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read MCP configuration from stdin: ${getErrorMessage(err)}`, { cause: err }); } const normalizedConfig = normalizeSinkVisibilityEncoding(mcpConfig); if (normalizedConfig !== mcpConfig) { @@ -811,7 +811,7 @@ async function main() { try { fs.chmodSync(outputPath, 0o600); } catch (err) { - throw new Error(`Failed to set permissions on ${outputPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to set permissions on ${outputPath}: ${getErrorMessage(err)}`, { cause: err }); } // Check for error payload @@ -889,7 +889,7 @@ async function main() { try { fs.mkdirSync(copilotConfigDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${copilotConfigDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${copilotConfigDir}: ${getErrorMessage(err)}`, { cause: err }); } const cliServersRaw = process.env.GH_AW_MCP_CLI_SERVERS; if (cliServersRaw) { @@ -911,21 +911,21 @@ async function main() { try { fs.copyFileSync(outputPath, copilotConfigFile); } catch (err) { - throw new Error(`Failed to copy file ${outputPath} to ${copilotConfigFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to copy file ${outputPath} to ${copilotConfigFile}: ${getErrorMessage(err)}`, { cause: err }); } } } else { try { fs.copyFileSync(outputPath, copilotConfigFile); } catch (err) { - throw new Error(`Failed to copy file ${outputPath} to ${copilotConfigFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to copy file ${outputPath} to ${copilotConfigFile}: ${getErrorMessage(err)}`, { cause: err }); } } let copilotConfigContent; try { copilotConfigContent = fs.readFileSync(copilotConfigFile, "utf8"); } catch (err) { - throw new Error(`Failed to read file ${copilotConfigFile}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to read file ${copilotConfigFile}: ${getErrorMessage(err)}`, { cause: err }); } core.info(copilotConfigContent); } @@ -972,7 +972,7 @@ async function main() { try { fs.mkdirSync(cliDir, { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${cliDir}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${cliDir}: ${getErrorMessage(err)}`, { cause: err }); } try { diff --git a/setup/js/threat_detection_warning.cjs b/setup/js/threat_detection_warning.cjs index 4202bff..ba8dadd 100644 --- a/setup/js/threat_detection_warning.cjs +++ b/setup/js/threat_detection_warning.cjs @@ -20,25 +20,6 @@ function normalizeThreatKinds(reason) { return kinds.length > 0 ? Array.from(new Set(kinds)).join(",") : "unknown"; } -/** - * Returns the XML marker used to identify threat-detected output. - * - * @param {string | undefined | null} reason - * @returns {string} - */ -function getThreatDetectedMarker(reason) { - return ""; -} - -/** - * Returns the marker template for configured message rendering. - * - * @returns {string} - */ -function getThreatDetectedMarkerTemplate() { - return ""; -} - /** * Returns a human-readable reason text for detection warnings. * @@ -69,10 +50,85 @@ function isToolingFailureReason(reason) { return normalized === "agent_failure" || normalized === "parse_error"; } +/** + * Returns the XML marker used to identify threat-engine-error output. + * This marker is distinct from the real-threat marker so that automated tools + * can distinguish a tooling failure from an actual security finding. + * + * @returns {string} + */ +function getThreatEngineErrorMarker() { + return ""; +} + +/** + * Returns the marker template for configured engine-error message rendering. + * + * @returns {string} + */ +function getThreatEngineErrorMarkerTemplate() { + return ""; +} + +/** + * Returns the review-warning presentation associated with a detection reason. + * Centralizing these fields keeps admonition copy and marker routing in sync + * across status messages, footers, and fallback pull request bodies. + * + * @param {string | undefined | null} reason + * @returns {{admonition: string, title: string, summary: string, marker: string}} + */ +function getThreatWarningPresentation(reason) { + if (isToolingFailureReason(reason)) { + return { + admonition: "WARNING", + title: "threat detection engine error", + summary: "The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.", + marker: getThreatEngineErrorMarker(), + }; + } + return { + admonition: "CAUTION", + title: "agentic threat detected", + summary: "Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.", + marker: getThreatDetectedMarker(reason), + }; +} + +/** + * Returns the XML marker used to identify threat-detected output. + * When the reason indicates a tooling failure (agent_failure, parse_error) a + * distinct engine-error marker is returned so automated tools can distinguish + * "detection engine crashed" from "detection engine found something". + * + * @param {string | undefined | null} reason + * @returns {string} + */ +function getThreatDetectedMarker(reason) { + if (isToolingFailureReason(reason)) { + return getThreatEngineErrorMarker(); + } + return ""; +} + +/** + * Returns the marker template for configured message rendering. + * Always returns the real-threat marker; use getThreatEngineErrorMarkerTemplate() + * for tooling-failure templates where the reason is known at template-build time. + * + * @returns {string} + */ +function getThreatDetectedMarkerTemplate() { + return ""; +} + module.exports = { normalizeThreatKinds, + getThreatWarningPresentation, getThreatDetectedMarker, getThreatDetectedMarkerTemplate, + getThreatEngineErrorMarker, + getThreatEngineErrorMarkerTemplate, getDetectionReasonText, isToolingFailureReason, }; diff --git a/setup/js/update_pull_request.cjs b/setup/js/update_pull_request.cjs index bc51540..f35809f 100644 --- a/setup/js/update_pull_request.cjs +++ b/setup/js/update_pull_request.cjs @@ -24,13 +24,19 @@ const { withRetry, isTransientError } = require("./error_recovery.cjs"); * @returns {boolean} */ function isNonFatalUpdateBranchError(error) { + // Resolve the effective HTTP status by checking the error and its .originalError chain. + // withRetry wraps the original error in an enhanced error that lacks .status, so we need + // to walk the chain to find the underlying status from the GitHub API response. /** @type {number | undefined} */ let status; - if (typeof error === "object" && error !== null && "status" in error) { - const candidateStatus = error.status; - if (typeof candidateStatus === "number") { - status = candidateStatus; + /** @type {any} */ + let current = error; + while (current !== null && typeof current === "object") { + if ("status" in current && typeof current.status === "number") { + status = current.status; + break; } + current = current.originalError ?? null; } const message = getErrorMessage(error).toLowerCase(); const hasWorkflowsPermissionPhrase = /without\s+`?workflows`?\s+permission/i.test(message); @@ -54,12 +60,16 @@ function isNonFatalUpdateBranchError(error) { // GitHub update-branch API can return these 422 messages for benign conditions: // - already up to date ("There are no new commits on the base branch") // - cannot auto-update due to conflict ("merge conflict between base and head") + // - stale merged targets where the head branch was deleted ("head ref does not exist") // These should not fail safe output processing. - // hasWorkflowsPermissionError / hasWorkflowsScopeRequired are only checked here for errors - // with no numeric status (status === undefined). The explicit 403 case is already handled - // by the if-block above, and other numeric statuses (e.g. 422 with these phrases) should - // not be silently swallowed. - return message.includes("there are no new commits on the base branch") || message.includes("merge conflict between base and head") || ((hasWorkflowsPermissionError || hasWorkflowsScopeRequired) && status === undefined); + // Restrict to status === 422 to avoid silently swallowing the same phrases from proxy/network + // errors that lack a numeric status. hasWorkflowsPermissionError / hasWorkflowsScopeRequired + // are only checked for errors with no numeric status (status === undefined); the explicit 403 + // case is already handled by the if-block above. + return ( + (status === 422 && (message.includes("there are no new commits on the base branch") || message.includes("merge conflict between base and head") || message.includes("head ref does not exist"))) || + ((hasWorkflowsPermissionError || hasWorkflowsScopeRequired) && status === undefined) + ); } /** diff --git a/setup/js/upload_artifact.cjs b/setup/js/upload_artifact.cjs index c2b141c..11a44cd 100644 --- a/setup/js/upload_artifact.cjs +++ b/setup/js/upload_artifact.cjs @@ -177,12 +177,12 @@ function copySingleFileToStaging(sourcePath, destRelPath) { try { fs.mkdirSync(path.dirname(destPath), { recursive: true }); } catch (err) { - throw new Error(`Failed to create directory ${path.dirname(destPath)}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to create directory ${path.dirname(destPath)}: ${getErrorMessage(err)}`, { cause: err }); } try { fs.copyFileSync(sourcePath, destPath); } catch (err) { - throw new Error(`Failed to copy file ${sourcePath} to ${destPath}: ${String(err)}`, { cause: err }); + throw new Error(`Failed to copy file ${sourcePath} to ${destPath}: ${getErrorMessage(err)}`, { cause: err }); } return { error: null }; } diff --git a/setup/md/detection_runs_comment.md b/setup/md/detection_runs_comment.md index 68cf428..edb408a 100644 --- a/setup/md/detection_runs_comment.md +++ b/setup/md/detection_runs_comment.md @@ -1,7 +1,14 @@ ### {workflow_name} +Threat detection produced a **{conclusion}** result for this run. + +
+Run details + | Field | Value | |---|---| | Conclusion | `{conclusion}` | | Reason | `{reason}` | | Run | [View run]({run_url}) | + +
diff --git a/setup/md/threat_detection_caution.md b/setup/md/threat_detection_caution.md new file mode 100644 index 0000000..b3577a7 --- /dev/null +++ b/setup/md/threat_detection_caution.md @@ -0,0 +1,12 @@ +> [!CAUTION] +> agentic threat detected +> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation. +> {threat_detected_marker} +> +>
+> Details +> +> {reason_text} +> +> Review the [workflow run logs]({run_url}) for details. +>
diff --git a/setup/md/threat_detection_engine_error.md b/setup/md/threat_detection_engine_error.md new file mode 100644 index 0000000..d910df5 --- /dev/null +++ b/setup/md/threat_detection_engine_error.md @@ -0,0 +1,11 @@ +> [!WARNING] +> **Threat Detection Engine Failure** — The analysis engine could not complete. This is a tooling failure, not a security finding. +> {threat_detected_marker} +> +>
+> What happened +> +> {reason_text} +> +> Review the [workflow run logs]({run_url}) for details. +>
diff --git a/setup/sh/collect_usage_artifact_files.sh b/setup/sh/collect_usage_artifact_files.sh new file mode 100644 index 0000000..e76ef5a --- /dev/null +++ b/setup/sh/collect_usage_artifact_files.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set +o histexpand +set -euo pipefail + +# Collect usage artifact files into /tmp/gh-aw/usage/ for upload. +# Copies aw_info, agent/detection usage JSONL, evals, rate limits, and +# token-usage logs from the firewall sandbox directories. +# +# Token-usage files are copied in ascending priority order so the last +# non-empty source wins: +# firewall-audit-logs/ (legacy) → firewall/audit/ (AWF audit) → firewall/logs/ (authoritative) +# The -s check (non-empty) prevents an empty stub file from zeroing out +# valid data already written by a higher-priority source. + +mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + +echo "Usage artifact source file status:" +for file in \ + /tmp/gh-aw/aw_info.json \ + /tmp/gh-aw/aw-info.jsonl \ + /tmp/gh-aw/agent_usage.json \ + /tmp/gh-aw/agent_usage.jsonl \ + /tmp/gh-aw/detection_usage.jsonl \ + /tmp/gh-aw/evals/evals.jsonl \ + /tmp/gh-aw/github_rate_limits.jsonl \ + /tmp/gh-aw/safe-output-items.jsonl \ + /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl \ + /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl \ + /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl \ + /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl \ + /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl \ + /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + if [ -f "$file" ]; then echo "FOUND: $file"; else echo "MISSING: $file"; fi +done + +if [ -f /tmp/gh-aw/aw_info.json ]; then cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true; fi +if [ -f /tmp/gh-aw/aw-info.jsonl ]; then cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true; fi +if [ -f /tmp/gh-aw/agent_usage.json ]; then cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true; fi +if [ -f /tmp/gh-aw/agent_usage.jsonl ]; then cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true; fi +if [ -f /tmp/gh-aw/detection_usage.jsonl ]; then cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true; fi +if [ -f /tmp/gh-aw/evals/evals.jsonl ]; then cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true; fi +if [ -f /tmp/gh-aw/github_rate_limits.jsonl ]; then cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true; fi + +# Agent token usage (ascending priority — last non-empty source wins). +if [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true; fi +if [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true; fi +if [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true; fi + +# Detection token usage (ascending priority — last non-empty source wins). +if [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true; fi +if [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true; fi +if [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ]; then cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true; fi + +[ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl +[ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + +mkdir -p /tmp/gh-aw/usage/activity +node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" +find /tmp/gh-aw/usage -type f -print | sort diff --git a/setup/sh/print_firewall_logs.sh b/setup/sh/print_firewall_logs.sh index 66cc637..341798c 100755 --- a/setup/sh/print_firewall_logs.sh +++ b/setup/sh/print_firewall_logs.sh @@ -50,3 +50,18 @@ if command -v awf &> /dev/null; then else echo 'AWF binary not installed, skipping firewall log summary' fi + +# Warn if Squid access.log is missing (current layout: squid-logs/; legacy layout: directly under logs/). +# A missing access.log means egress traffic for this run cannot be audited. +ACCESS_LOG_FOUND=false +for candidate in \ + "${AWF_LOGS_DIR}/squid-logs/access.log" \ + "${AWF_LOGS_DIR}/access.log"; do + if [[ -f "${candidate}" ]]; then + ACCESS_LOG_FOUND=true + break + fi +done +if [[ "${ACCESS_LOG_FOUND}" == "false" ]]; then + echo "WARNING: Squid access.log not found under ${AWF_LOGS_DIR}; egress traffic for this run cannot be audited." >&2 +fi