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
2 changes: 1 addition & 1 deletion setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
const eventPayload = invocationContext?.eventPayload || context.payload;
const eventRepo = invocationContext?.eventRepo || context.repo;
try {
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
const workflowName = process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow";
const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event";

// Use getRunStartedMessage for the workflow link text (supports custom messages)
Expand Down
62 changes: 60 additions & 2 deletions setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,54 @@ function reportCommentError(rawContext, message) {
core.setFailed(message);
}

/**
* @param {ReusableStatusComment} reusableComment
* @param {{
* source: "native" | "workflow_dispatch" | "repository_dispatch";
* eventName: string;
* eventPayload: any;
* workflowRepo: { owner: string, repo: string };
* eventRepo: { owner: string, repo: string };
* }} invocationContext
* @param {any} rawContext
* @returns {Promise<string>}
*/
async function updateReusableStatusComment(reusableComment, invocationContext, rawContext) {
const runUrl = buildWorkflowRunUrl(rawContext, invocationContext.workflowRepo);
const commentBody = buildCommentBody(invocationContext.eventName, runUrl);

// Discussion comments use GraphQL node IDs and a dedicated update mutation.
if (reusableComment.id.startsWith("DC_")) {
const result = await github.graphql(
`
mutation($commentId: ID!, $body: String!) {
updateDiscussionComment(input: { commentId: $commentId, body: $body }) {
comment { id url }
}
}`,
{ commentId: reusableComment.id, body: commentBody }
);
const updatedUrl = result?.updateDiscussionComment?.comment?.url;
return typeof updatedUrl === "string" && updatedUrl.trim() ? updatedUrl : reusableComment.url;
}

const commentRepo = reusableComment.repo || invocationContext.eventRepo;
const numericCommentId = Number(reusableComment.id);
if (!Number.isInteger(numericCommentId) || numericCommentId <= 0) {
throw new Error(`${ERR_VALIDATION}: Reusable status comment ID must be a positive integer (received "${reusableComment.id}")`);
}

const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
owner: commentRepo.owner,
repo: commentRepo.repo,
comment_id: numericCommentId,
body: commentBody,
headers: { Accept: "application/vnd.github+json" },
});
const updatedUrl = response?.data?.html_url;
return typeof updatedUrl === "string" && updatedUrl.trim() ? updatedUrl : reusableComment.url;
}

/**
* Add a comment with a workflow run link to the triggering item.
* This script ONLY creates comments - it does NOT add reactions.
Expand All @@ -190,7 +238,17 @@ async function createOrReuseStatusComment(rawContext = context) {
if (!reusableComment.repo) {
core.warning("Reusable status comment repo missing; falling back to the invocation event repo.");
}
const outputs = setCommentOutputs(reusableComment.id, reusableComment.url, reusableComment.repo || invocationContext.eventRepo, { logReuse: true });
let reusableCommentUrl = reusableComment.url;
try {
reusableCommentUrl = await updateReusableStatusComment(reusableComment, invocationContext, rawContext);
core.info("Updated reusable status comment with current workflow run metadata");
} catch (error) {
core.warning(`Failed to update reusable status comment body: ${getErrorMessage(error)}`);
if (!reusableCommentUrl) {
core.warning("No fallback reusable status comment URL available; comment-url output will be empty.");
}
}
const outputs = setCommentOutputs(reusableComment.id, reusableCommentUrl, reusableComment.repo || invocationContext.eventRepo, { logReuse: true });
return {
...outputs,
reused: true,
Expand Down Expand Up @@ -282,7 +340,7 @@ async function main() {
* @returns {string} The assembled comment body
*/
function buildCommentBody(eventName, runUrl) {
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
const workflowName = process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow";
const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event";

// Sanitize before adding markers (defense in depth for custom message templates)
Expand Down
33 changes: 19 additions & 14 deletions setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,24 @@ function parseMaxAICreditsExceededFromAuditLog(auditJsonlPathOverride) {
);
}

/**
* @param {unknown} entry
* @returns {boolean}
*/
function parseUnknownModelAICreditsFromAuditEntry(entry) {
if (!entry || typeof entry !== "object") return false;
const stack = [entry];
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== "object") continue;
for (const [, value] of Object.entries(node)) {
if (value === UNKNOWN_MODEL_AI_CREDITS_TYPE) return true;
if (value && typeof value === "object") stack.push(value);
}
}
return false;
}

/**
* Detects an `unknown_model_ai_credits` error from the firewall audit log.
* This HTTP 400 error is emitted by the AWF API proxy when `maxAiCredits` is active and
Expand All @@ -268,20 +286,7 @@ function parseUnknownModelAICreditsFromAuditLog(auditJsonlPathOverride) {
auditJsonlPathOverride,
false,
content => content.includes(UNKNOWN_MODEL_AI_CREDITS_TYPE),
(acc, entry) => {
if (acc) return true;
if (!entry || typeof entry !== "object") return false;
const stack = [entry];
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== "object") continue;
for (const [, value] of Object.entries(node)) {
if (value === UNKNOWN_MODEL_AI_CREDITS_TYPE) return true;
if (value && typeof value === "object") stack.push(value);
}
}
return false;
}
(acc, entry) => acc || parseUnknownModelAICreditsFromAuditEntry(entry)
);
}

Expand Down
2 changes: 1 addition & 1 deletion setup/js/allowed_issue_fields.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function validateAllowedIssueFields(issueFields, allowedFields) {
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
return;
}
const allowedFieldSet = new Set(allowedFields.map(f => f.toLowerCase()));
const allowedFieldSet = new Set(allowedFields.map(field => field.toLowerCase()));
if (allowedFieldSet.has("*")) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions setup/js/assign_agent_helpers.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
/// <reference types="@actions/github-script" />
// @safe-outputs-exempt SEC-004 — body fields are read-only API context, never written back

const { getErrorMessage } = require("./error_helpers.cjs");

Expand Down
8 changes: 7 additions & 1 deletion setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,13 +331,15 @@ async function fetchAWFReflect(options) {
*
* @param {{
* model?: string,
* provider?: string,
* reflectData: object | null | undefined,
* logger?: (msg: string) => void,
* }} [options]
* @returns {{ model: string, provider: { type: "openai", baseUrl: string } } | null}
*/
function resolveCopilotSDKCustomProviderFromReflect(options) {
const configuredModel = typeof options?.model === "string" ? options.model.trim() : "";
const configuredProvider = typeof options?.provider === "string" ? options.provider.trim().toLowerCase() : "";
const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER;

const reflectData = options?.reflectData;
Expand All @@ -352,7 +354,11 @@ function resolveCopilotSDKCustomProviderFromReflect(options) {
return null;
}

const endpoint = (configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) || endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") || endpoints[0];
const endpoint =
(configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) ||
(configuredProvider ? endpoints.find(ep => String(ep.provider || "").toLowerCase() === configuredProvider) : null) ||
endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") ||
endpoints[0];

let baseUrl = "";
if (typeof endpoint?.models_url === "string" && endpoint.models_url) {
Expand Down
3 changes: 2 additions & 1 deletion setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,8 @@ async function main() {
let resolvedModel = "";
if (copilotSDKMode) {
const configuredModel = process.env.COPILOT_MODEL || "";
const customProvider = resolveCopilotSDKCustomProviderFromReflect({ model: configuredModel, reflectData: awfReflectData, logger: log });
const configuredProvider = process.env.GH_AW_LLM_PROVIDER || "";
const customProvider = resolveCopilotSDKCustomProviderFromReflect({ model: configuredModel, provider: configuredProvider, reflectData: awfReflectData, logger: log });
if (!customProvider) {
log("copilot-sdk driver mode: BYOK provider is required but could not be resolved from awf-reflect data — aborting");
process.exit(1);
Expand Down
10 changes: 10 additions & 0 deletions setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,13 +1625,18 @@ async function main(config = {}) {
const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`;
const fallbackBundleTempRef = createBundleTempRef(branchName);
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(pushError instanceof Error ? pushError.message : String(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
const fallbackBody = `${issueSafeBody}

---

> [!NOTE]
> This was originally intended as a pull request, but the git push operation failed.
>
> **Original error:** ${pushFailureMessage}
>
> **Workflow Run:** [View run details and download bundle artifact](${runUrl})
>
> The bundle file is available in the \`agent\` artifact in the workflow run linked above.
Expand Down Expand Up @@ -1966,13 +1971,18 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
}

const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(pushError instanceof Error ? pushError.message : String(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
const fallbackBody = `${issueSafeBody}

---

> [!NOTE]
> This was originally intended as a pull request, but the git push operation failed.
>
> **Original error:** ${pushFailureMessage}
>
> **Workflow Run:** [View run details and download patch artifact](${runUrl})
>
> The patch file is available in the \`agent\` artifact in the workflow run linked above.
Expand Down
115 changes: 111 additions & 4 deletions setup/js/generate_usage_activity_summary.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,85 @@ const path = require("path");

const SQUID_STATUS_INDEX = 6;
const SQUID_DECISION_INDEX = 7;
const SQUID_DOMAIN_INDEX = 2;
const SQUID_DEST_INDEX = 3;
const SQUID_CLIENT_INDEX = 1;
const LOCALHOST_CLIENT_PREFIX = "::1:";
const PLACEHOLDER_DOMAIN_KEY = "-";
const PLACEHOLDER_DEST_KEY = "-:-";
const ERROR_DOMAIN_PREFIX = "error:";

/**
* Check if a Squid decision indicates an allowed request
*/
function isAllowedDecision(decision) {
const base = decision.split("/")[0].trim().toUpperCase();
// Squid decision tokens appear in multiple formats (for example
// TCP_TUNNEL:HIER_DIRECT and TCP_MISS/200), so normalize on the leading verb.
const base = decision.trim().toUpperCase().split(/[/:]/)[0];
return ["TCP_TUNNEL", "TCP_HIT", "TCP_MISS"].includes(base);
}

/**
* Resolve the domain key used in aggregate firewall stats.
*
* @param {string} domain
* @param {string} dest
* @returns {string}
*/
function getFirewallDomainKey(domain, dest) {
// Squid can emit either "-" or "-:-" for missing destination fields, so both
// placeholders are treated as invalid destination keys.
if (domain !== PLACEHOLDER_DOMAIN_KEY) {
return domain;
}
if (!isPlaceholderFirewallField(dest)) {
return dest;
}
return PLACEHOLDER_DOMAIN_KEY;
}

/**
* @param {string} value
* @returns {boolean}
*/
function isPlaceholderFirewallField(value) {
return value === PLACEHOLDER_DEST_KEY || value === PLACEHOLDER_DOMAIN_KEY;
}

/**
* @param {string} domain
* @returns {boolean}
*/
function isValidDomainKey(domain) {
return domain !== PLACEHOLDER_DOMAIN_KEY && !domain.startsWith(ERROR_DOMAIN_PREFIX);
}

/**
* @param {string} client
* @param {string} domain
* @param {string} dest
* @returns {boolean}
*/
function isInternalFirewallErrorEntry(client, domain, dest) {
return client.startsWith(LOCALHOST_CLIENT_PREFIX) && domain === PLACEHOLDER_DOMAIN_KEY && isPlaceholderFirewallField(dest);
}

/**
* Parse firewall logs and aggregate request counts
*/
function parseFirewallLogs() {
const firewall = { total_requests: 0, allowed_requests: 0, blocked_requests: 0 };
const firewall = {
total_requests: 0,
allowed_requests: 0,
blocked_requests: 0,
allowed_domains: new Set(),
blocked_domains: new Set(),
requests_by_domain: {},
};

const firewallPaths = ["/tmp/gh-aw/sandbox/firewall/logs/*.log", "/tmp/gh-aw/threat-detection/sandbox/firewall/logs/*.log", "/tmp/gh-aw/squid-logs-*/*.log", "/tmp/gh-aw/threat-detection/squid-logs-*/*.log"];
// The sandbox firewall logs may be emitted in nested directories (for example,
// api-proxy-logs/*.log), so these patterns are intentionally recursive.
const firewallPaths = ["/tmp/gh-aw/sandbox/firewall/logs/**/*.log", "/tmp/gh-aw/threat-detection/sandbox/firewall/logs/**/*.log", "/tmp/gh-aw/squid-logs-*/**/*.log", "/tmp/gh-aw/threat-detection/squid-logs-*/**/*.log"];

for (const pattern of firewallPaths) {
const files = globSync(pattern);
Expand All @@ -48,6 +111,23 @@ function parseFirewallLogs() {
continue;
}

const domain = parts[SQUID_DOMAIN_INDEX];
const dest = parts[SQUID_DEST_INDEX];
const client = parts[SQUID_CLIENT_INDEX] || "";
const isInternalErrorEntry = isInternalFirewallErrorEntry(client, domain, dest);
if (isInternalErrorEntry) {
continue;
}

// Domain key resolution intentionally considers both domain and dest
// because Squid may leave domain unset while dest remains usable.
const domainKey = getFirewallDomainKey(domain, dest);
// Keep total/allowed/blocked counters aligned with per-domain buckets by
// excluding unresolved placeholder/error keys from both representations.
if (!isValidDomainKey(domainKey)) {
continue;
}

firewall.total_requests += 1;

// Squid access log columns (0-based):
Expand All @@ -67,10 +147,18 @@ function parseFirewallLogs() {
allowed = true;
}

if (!firewall.requests_by_domain[domainKey]) {
firewall.requests_by_domain[domainKey] = { allowed: 0, blocked: 0 };
}

if (allowed) {
firewall.allowed_requests += 1;
firewall.requests_by_domain[domainKey].allowed += 1;
firewall.allowed_domains.add(domainKey);
} else {
firewall.blocked_requests += 1;
firewall.requests_by_domain[domainKey].blocked += 1;
firewall.blocked_domains.add(domainKey);
}
}
} catch (err) {
Expand All @@ -80,7 +168,26 @@ function parseFirewallLogs() {
}
}

return firewall.total_requests > 0 ? firewall : null;
if (firewall.total_requests === 0) {
return null;
}

const requestsByDomain = {};
for (const [domain, stats] of Object.entries(firewall.requests_by_domain)) {
if (!isValidDomainKey(domain)) {
continue;
}
requestsByDomain[domain] = stats;
}

return {
total_requests: firewall.total_requests,
allowed_requests: firewall.allowed_requests,
blocked_requests: firewall.blocked_requests,
allowed_domains: Array.from(firewall.allowed_domains).filter(isValidDomainKey).sort(),
blocked_domains: Array.from(firewall.blocked_domains).filter(isValidDomainKey).sort(),
requests_by_domain: requestsByDomain,
};
}

/**
Expand Down
Loading
Loading