Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions setup/js/Dockerfile.safe-outputs-mcp
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
4 changes: 2 additions & 2 deletions setup/js/apply_safe_outputs_replay.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
});
}

Expand Down
80 changes: 53 additions & 27 deletions setup/js/artifact_client.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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()}`);
}
Expand Down Expand Up @@ -308,22 +323,28 @@ 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(
`/repos/${findBy.repositoryOwner}/${findBy.repositoryName}/actions/artifacts/${artifactId}/zip`,
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}`);
}
Expand All @@ -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})`);
}
Expand Down
4 changes: 2 additions & 2 deletions setup/js/build_checkout_manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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 };
Expand Down
10 changes: 7 additions & 3 deletions setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
}
Expand Down
3 changes: 2 additions & 1 deletion setup/js/check_version_updates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup/js/check_workflow_recompile_needed.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions setup/js/checkout_pr_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
19 changes: 17 additions & 2 deletions setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading