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
136 changes: 133 additions & 3 deletions setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ function isTrueLike(value) {
return value === true || value === "true" || value === 1 || value === "1";
}

/**
* @param {unknown} value
* @returns {string}
*/
function sanitizeModelName(value) {
return typeof value === "string" ? value.replace(/\r?\n|\r/g, " ").trim() : "";
}

/**
* @param {string} [auditJsonlPathOverride]
* @returns {string}
Expand All @@ -95,6 +103,44 @@ function resolveFirewallAuditLogPath(auditJsonlPathOverride) {
return path.join(candidateBases[0], "log.jsonl");
}

/**
* @param {string} [auditJsonlPathOverride]
* @returns {string[]}
*/
function resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride) {
if (auditJsonlPathOverride) return [auditJsonlPathOverride];
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
const roots = [];
if (agentOutputFile) {
roots.push(path.dirname(agentOutputFile));
}

/** @type {string[]} */
const candidates = [];
const seen = new Set();
const addCandidate = candidate => {
if (!candidate || seen.has(candidate)) return;
seen.add(candidate);
candidates.push(candidate);
};

for (const root of roots) {
addCandidate(path.join(root, "sandbox", "firewall", "logs", "api-proxy-logs", "event-logs.jsonl"));
addCandidate(path.join(root, "sandbox", "firewall", "logs", "api-proxy-logs", "events.jsonl"));
addCandidate(path.join(root, "sandbox", "firewall", "audit", "api-proxy-logs", "event-logs.jsonl"));
addCandidate(path.join(root, "sandbox", "firewall", "audit", "api-proxy-logs", "events.jsonl"));
}

addCandidate("/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/event-logs.jsonl");
addCandidate("/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/events.jsonl");
addCandidate("/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/event-logs.jsonl");
addCandidate("/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/events.jsonl");
addCandidate("/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/event-logs.jsonl");
addCandidate("/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/events.jsonl");
addCandidate(resolveFirewallAuditLogPath());
return candidates;
}

/**
* Depth-first traversal of a nested object, calling visitor for each [key, value] pair.
* Traversal stops early if visitor returns true.
Expand Down Expand Up @@ -193,6 +239,48 @@ function iterateAuditEntries(auditJsonlPathOverride, defaultValue, contentGuard,
}
}

/**
* Iterates one or more JSONL files, accumulating parsed entries across every existing file.
* Missing, unreadable, or malformed files/lines are ignored.
*
* @template T
* @param {string[]} filePaths
* @param {T} defaultValue
* @param {((content: string) => boolean) | null} contentGuard
* @param {(acc: T, entry: unknown) => T | undefined} accumulate
* @param {(acc: T) => boolean} [shouldStop]
* @returns {T}
*/
function iterateJSONLFiles(filePaths, defaultValue, contentGuard, accumulate, shouldStop) {
let result = defaultValue;
try {
for (const filePath of filePaths) {
try {
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, "utf8");
if (!content.trim()) continue;
if (contentGuard && !contentGuard(content)) continue;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed[0] !== "{") continue;
try {
const nextResult = accumulate(result, JSON.parse(trimmed));
if (nextResult !== undefined) result = nextResult;
if (shouldStop && shouldStop(result)) return result;
} catch {
// ignore malformed lines
}
}
} catch {
// ignore unreadable files and continue to the next candidate
}
}
return result;
} catch {
return defaultValue;
}
}

/**
* @param {string} [auditJsonlPathOverride]
* @returns {string}
Expand Down Expand Up @@ -288,11 +376,52 @@ function parseUnknownModelAICreditsFromAuditEntry(entry) {
* @returns {boolean}
*/
function parseUnknownModelAICreditsFromAuditLog(auditJsonlPathOverride) {
return iterateAuditEntries(
auditJsonlPathOverride,
return iterateJSONLFiles(
resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride),
false,
content => content.includes(UNKNOWN_MODEL_AI_CREDITS_TYPE),
(acc, entry) => acc || parseUnknownModelAICreditsFromAuditEntry(entry)
(acc, entry) => acc || parseUnknownModelAICreditsFromAuditEntry(entry),
acc => acc
);
}

/**
* Detects `unknown_model_ai_credits` from the firewall event/audit JSONL logs and extracts the model name.
* Structured entries emitted by the AWF API proxy carry both the error type and the model name, e.g.:
* { "type": "unknown_model_ai_credits", "model": "claude-opus-5" }
*
* @param {string} [auditJsonlPathOverride]
* @returns {{ detected: boolean, modelName: string }}
*/
function parseUnknownModelAICreditsAndModelFromAuditLog(auditJsonlPathOverride) {
/** @type {{ detected: boolean, modelName: string }} */
const initial = { detected: false, modelName: "" };
return iterateJSONLFiles(
resolveUnknownModelAICreditsLogPaths(auditJsonlPathOverride),
initial,
content => content.includes(UNKNOWN_MODEL_AI_CREDITS_TYPE),
/**
* @param {{ detected: boolean, modelName: string }} acc
* @param {unknown} entry
* @returns {{ detected: boolean, modelName: string } | undefined}
*/
(acc, entry) => {
if (acc.detected && acc.modelName) return acc; // fully resolved, skip remaining entries
if (!parseUnknownModelAICreditsFromAuditEntry(entry)) return undefined; // not a matching entry
let modelName = acc.modelName;
if (!modelName) {
traverseObjectTree(entry, (key, value) => {
const sanitized = sanitizeModelName(value);
if (key === "model" && sanitized) {
modelName = sanitized;
return true;
}
return false;
});
}
return { detected: true, modelName };
},
acc => acc.detected && !!acc.modelName
);
}

Expand Down Expand Up @@ -422,5 +551,6 @@ module.exports = {
parseAICreditsErrorInfoFromAuditLog,
parseMaxAICreditsExceededFromAuditLog,
parseUnknownModelAICreditsFromAuditLog,
parseUnknownModelAICreditsAndModelFromAuditLog,
resolveAICreditsFailureState,
};
60 changes: 56 additions & 4 deletions setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,30 @@ async function derivePrHeadRef(entry) {
if (ref) return ref;
}

// 3. Explicit pull_request_number on the sample arguments.
const argNumber = Number(entry.arguments.pull_request_number);
if (Number.isFinite(argNumber) && argNumber > 0) {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber });
// 3. PR number from sample arguments, workflow_dispatch inputs, or config target.
const pullNumber =
toPositivePullRequestNumber(entry.arguments.pull_request_number) ||
toPositivePullRequestNumber(payload?.inputs?.pull_request_number) ||
toPositivePullRequestNumber(payload?.client_payload?.pull_request_number) ||
readConfiguredTargetPullRequestNumber(entry.tool);
if (pullNumber) {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber });
if (ref) return ref;
}

return null;
}

/**
* Convert unknown value to a positive pull request number, or null.
* @param {any} value
* @returns {number|null}
*/
function toPositivePullRequestNumber(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : null;
}

/**
* Read the configured `target-repo` for a given safe-output tool from the
* safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty
Expand Down Expand Up @@ -291,6 +305,44 @@ function readConfiguredTargetRepo(tool) {
return "";
}

/**
* Read configured `target` for a safe-output tool and coerce it into a PR number.
* Supports plain numeric values and `${ENV_VAR}` placeholders.
* @param {string} tool
* @returns {number|null}
*/
function readConfiguredTargetPullRequestNumber(tool) {
const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
if (!configPath || !configPath.trim()) {
return null;
}

const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : "";

try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {};
const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null;
const target = toolConfig && typeof toolConfig === "object" ? toolConfig.target : null;

if (typeof target === "number") {
return toPositivePullRequestNumber(target);
}
if (typeof target === "string") {
const trimmed = target.trim();
const envMatch = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed);
if (envMatch) {
return toPositivePullRequestNumber(process.env[envMatch[1]]);
}
return toPositivePullRequestNumber(trimmed);
}
} catch (err) {
core.debug(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`);
}
return null;
}

/**
* Resolve the on-disk working directory in which a sample's patch should be
* staged (branch created + patch committed).
Expand Down
Loading
Loading