diff --git a/setup/js/allowed_issue_fields.cjs b/setup/js/allowed_issue_fields.cjs index 1b1770f..7436cc3 100644 --- a/setup/js/allowed_issue_fields.cjs +++ b/setup/js/allowed_issue_fields.cjs @@ -52,8 +52,17 @@ function validateAllowedIssueFields(issueFields, allowedFields) { if (!Array.isArray(issueFields) || issueFields.length === 0) { return; } + if (!Array.isArray(allowedFields) || allowedFields.length === 0) { + return; + } + const allowedFieldSet = new Set(allowedFields.map(f => f.toLowerCase())); + if (allowedFieldSet.has("*")) { + return; + } for (const field of issueFields) { - validateAllowedIssueFieldName(field.name, allowedFields); + if (!allowedFieldSet.has(field.name.toLowerCase())) { + throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" is not in the allowed-fields list: ${allowedFields.join(", ")}`); + } } } diff --git a/setup/js/check_command_position.cjs b/setup/js/check_command_position.cjs index 39419df..e945c7b 100644 --- a/setup/js/check_command_position.cjs +++ b/setup/js/check_command_position.cjs @@ -3,6 +3,7 @@ const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); const { writeDenialSummary } = require("./pre_activation_summary.cjs"); +const { matchesCommandName, resolveMatchedCommand } = require("./slash_command_matcher.cjs"); /** * Check if command is the first word in the triggering text @@ -90,7 +91,7 @@ async function main() { } if (inboundCommandName) { - if (commands.includes(inboundCommandName)) { + if (commands.some(command => matchesCommandName(command, inboundCommandName))) { core.info(`✓ command_name '${inboundCommandName}' resolved from workflow_dispatch aw_context`); core.setOutput("command_position_ok", "true"); core.setOutput("matched_command", inboundCommandName); @@ -116,24 +117,14 @@ async function main() { return; } - // Normalize whitespace and get the first word + // Normalize whitespace and resolve the matched slash command at the start of the text. const trimmedText = text.trim(); + const matchedCommand = resolveMatchedCommand(trimmedText, commands); const firstWord = trimmedText.split(/\s+/)[0]; core.info(`Checking command position. First word in text: ${firstWord}`); core.info(`Looking for commands: ${commands.map(c => `/${c}`).join(", ")}`); - // Check if any of the commands match - let matchedCommand = null; - for (const command of commands) { - const expectedCommand = `/${command}`; - - if (firstWord === expectedCommand) { - matchedCommand = command; - break; - } - } - if (matchedCommand) { core.info(`✓ Command '/${matchedCommand}' matched at the start of the text`); core.setOutput("command_position_ok", "true"); diff --git a/setup/js/copilot_harness.cjs b/setup/js/copilot_harness.cjs index 9d02b4a..7b58d67 100644 --- a/setup/js/copilot_harness.cjs +++ b/setup/js/copilot_harness.cjs @@ -42,9 +42,8 @@ require("./shim.cjs"); const fs = require("fs"); -const path = require("path"); const crypto = require("crypto"); -const { renderTemplateFromFile } = require("./messages_core.cjs"); +const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs"); const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs"); const { @@ -81,7 +80,7 @@ const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB"; const MAX_ENV_VAR_PREVIEW_LENGTH = 120; const OUTPUT_TAIL_MAX_CHARS = 600; const OUTPUT_TAIL_MAX_LINES = 12; -const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH = path.join(__dirname, "../md/copilot_requests_proxy_auth_403.md"); +const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md"; // Pattern to detect transient CAPIError 400 in copilot output const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/; @@ -420,9 +419,10 @@ function envFlagEnabled(value) { * Build a more actionable Copilot auth diagnostic when a 401/403 came from the gh-aw API proxy. * @param {string} output * @param {NodeJS.ProcessEnv} [env] + * @param {{ renderTemplateFromFile?: typeof renderTemplateFromFile }} [options] * @returns {string} */ -function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) { +function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env, options = {}) { const authFailure = parseProviderAuthFailure(output); if (!authFailure || !isLikelyAWFAPIProxyURL(authFailure.providerUrl)) { return ""; @@ -431,7 +431,8 @@ function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) { const selectedModel = typeof env.COPILOT_MODEL === "string" && env.COPILOT_MODEL.trim() ? env.COPILOT_MODEL.trim() : "(unset)"; const stage = detectCopilotAuthFailureStage(output); if (authFailure.statusCode === "403" && envFlagEnabled(env.S2STOKENS)) { - return renderTemplateFromFile(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH, { + const render = options.renderTemplateFromFile || renderTemplateFromFile; + return render(getPromptPath(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME), { selected_model: selectedModel, stage, }); diff --git a/setup/js/copilot_sdk_session.cjs b/setup/js/copilot_sdk_session.cjs index b709fb8..9f3129c 100644 --- a/setup/js/copilot_sdk_session.cjs +++ b/setup/js/copilot_sdk_session.cjs @@ -9,7 +9,7 @@ * * Event mapping: * SDK "user.message" → JSONL "user.message" - * SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName) + * SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName, command?) * SDK "tool.execution_complete" → JSONL "tool.execution_complete" (toolName, mcpServerName, success, result) * SDK "assistant.message" → JSONL "assistant.message" (content) * @@ -29,6 +29,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); const { buildCopilotSDKPermissionHandler, getEnvPositiveIntOrDefault, parseMaxToolDenialsLimit, MAX_TOOL_DENIALS_DEFAULT } = require("./copilot_sdk_permissions.cjs"); +const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); // Default timeout for a single sendAndWait call: 10 minutes. // This is intentionally generous — the headless Copilot CLI has its own internal @@ -36,6 +37,12 @@ const { buildCopilotSDKPermissionHandler, getEnvPositiveIntOrDefault, parseMaxTo // Override via the COPILOT_SDK_SEND_TIMEOUT_MS environment variable. const SDK_SEND_TIMEOUT_MS_DEFAULT = 10 * 60 * 1000; +// Pattern matching the SDK idle-timeout error emitted when sendAndWait reaches its +// deadline waiting for the session.idle event. This matches the message format +// "Timeout after ms waiting for session.idle" produced by the Copilot SDK. +// Keep in sync with SDK_SESSION_IDLE_TIMEOUT_PATTERN in copilot_harness.cjs. +const SDK_IDLE_TIMEOUT_PATTERN = /Timeout after \d+ms waiting for session\.idle/; + /** * Extract the prompt text from a resolved args array. * Looks for the first occurrence of "-p " or "--prompt ". @@ -141,6 +148,13 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c let toolDenialCount = 0; let catastrophicToolDenialsError = null; let catastrophicToolDenialsTriggered = false; + /** + * Map from toolCallId → {toolName, mcpServerName} for enriching tool.execution_complete + * events and for tracking in-flight tool calls when the idle-timeout fires. + * Declared at function scope so the catch block can check pendingToolCalls.size. + * @type {Map} + */ + const pendingToolCalls = new Map(); /** * Best-effort write of a driver-level event to events.jsonl and stderr. @@ -214,13 +228,6 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c const stream = eventsStream; log(`serialising SDK events to ${eventsPath}`); - /** - * Map from toolCallId → {toolName, mcpServerName} so that tool.execution_complete - * events (which carry no mcpServerName) can be enriched from the matching start event. - * @type {Map} - */ - const pendingToolCalls = new Map(); - /** * Write one JSONL entry to the events file and stderr. * Uses the event's own ISO-8601 timestamp when available. @@ -250,10 +257,12 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c const toolName = event.data?.toolName ?? "unknown"; const mcpServerName = event.data?.mcpServerName ?? ""; const toolCallId = event.data?.toolCallId; + const command = extractShellCommandFromToolData(event.data); if (toolCallId) { pendingToolCalls.set(toolCallId, { toolName, mcpServerName }); } - writeEvent("tool.execution_start", { toolName, mcpServerName }, event.timestamp); + const eventData = command ? { toolName, mcpServerName, command } : { toolName, mcpServerName }; + writeEvent("tool.execution_start", eventData, event.timestamp); break; } @@ -316,10 +325,24 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c const durationMs = Date.now() - startTime; const failure = catastrophicToolDenialsError ?? (err instanceof Error ? err : new Error(String(err))); log(`error: ${failure.message}`); + + // When sendAndWait times out waiting for session.idle but the agent produced + // output and all tracked tool calls have already completed, the session work is + // done — the SDK simply failed to emit the idle signal. Treat it as a successful + // run so the harness does not classify it as a failure or waste retry attempts. + const isIdleTimeout = !catastrophicToolDenialsError && SDK_IDLE_TIMEOUT_PATTERN.test(failure.message); + if (isIdleTimeout && hasOutput && pendingToolCalls.size === 0) { + log(`warning: SDK idle-timeout with collected output and no pending tool calls — treating as completed`); + log(`session completed: hasOutput=${hasOutput} durationMs=${durationMs}`); + return { exitCode: 0, output, hasOutput, durationMs }; + } + + // Preserve any output collected before the error so the harness can use it + // for retry decisions and diagnostics. return { exitCode: 1, - output: failure.message, - hasOutput: false, + output: hasOutput ? output : failure.message, + hasOutput, durationMs, }; } finally { @@ -345,4 +368,4 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c } } -module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, extractPromptFromArgs, runWithCopilotSDK }; +module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, SDK_IDLE_TIMEOUT_PATTERN, extractPromptFromArgs, runWithCopilotSDK }; diff --git a/setup/js/generate_usage_activity_summary.cjs b/setup/js/generate_usage_activity_summary.cjs new file mode 100644 index 0000000..8521eac --- /dev/null +++ b/setup/js/generate_usage_activity_summary.cjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +// This script aggregates usage activity data from various log sources and generates +// a compact summary.json file for the usage artifact. +// usage-activity-summary/v1 structure: +// firewall: total/allowed/blocked request counters +// session: aggregate Copilot session event counters +// gateway: total/failed tool-call counters with per-server breakdown + +const fs = require("fs"); +const { globSync } = require("node:fs"); +const path = require("path"); + +const SQUID_STATUS_INDEX = 6; +const SQUID_DECISION_INDEX = 7; + +/** + * Check if a Squid decision indicates an allowed request + */ +function isAllowedDecision(decision) { + const base = decision.split("/")[0].trim().toUpperCase(); + return ["TCP_TUNNEL", "TCP_HIT", "TCP_MISS"].includes(base); +} + +/** + * Parse firewall logs and aggregate request counts + */ +function parseFirewallLogs() { + const firewall = { total_requests: 0, allowed_requests: 0, blocked_requests: 0 }; + + 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); + for (const logPath of files) { + try { + const content = fs.readFileSync(logPath, "utf-8"); + const lines = content.split("\n"); + + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const parts = line.split(/\s+/); + if (parts.length < 8) { + continue; + } + + firewall.total_requests += 1; + + // Squid access log columns (0-based): + // 0=timestamp 1=client 2=domain 3=dest 4=proto 5=method + // 6=status 7=decision 8=url 9=user-agent + // Keep indices named for easier maintenance if format changes. + const status = parts[SQUID_STATUS_INDEX]; + const decision = parts[SQUID_DECISION_INDEX]; + + let allowed = false; + const code = parseInt(status, 10); + if (!isNaN(code) && [200, 206, 304].includes(code)) { + allowed = true; + } + + if (!allowed && isAllowedDecision(decision)) { + allowed = true; + } + + if (allowed) { + firewall.allowed_requests += 1; + } else { + firewall.blocked_requests += 1; + } + } + } catch (err) { + // Skip files that can't be read + continue; + } + } + } + + return firewall.total_requests > 0 ? firewall : null; +} + +/** + * Parse Copilot session event logs and aggregate counters + */ +function parseSessionLogs() { + const session = { + total_events: 0, + session_starts: 0, + session_shutdowns: 0, + turns: 0, + assistant_messages: 0, + reasoning_events: 0, + tool_execution_starts: 0, + tool_execution_completes: 0, + failed_tool_executions: 0, + }; + + const sessionPaths = ["/tmp/gh-aw/sandbox/agent/logs/copilot-session-state/*/events.jsonl", "/tmp/gh-aw/threat-detection/sandbox/agent/logs/copilot-session-state/*/events.jsonl"]; + + for (const pattern of sessionPaths) { + const files = globSync(pattern); + for (const eventsPath of files) { + try { + const content = fs.readFileSync(eventsPath, "utf-8"); + const lines = content.split("\n"); + + for (const raw of lines) { + const line = raw.trim(); + if (!line || !line.startsWith("{")) { + continue; + } + + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + const eventType = String(entry.type || "") + .trim() + .toLowerCase(); + session.total_events += 1; + + if (eventType === "session.start") { + session.session_starts += 1; + } else if (eventType === "session.shutdown") { + session.session_shutdowns += 1; + } else if (eventType === "user.message") { + session.turns += 1; + } else if (eventType === "assistant.message") { + session.assistant_messages += 1; + } + // Copilot session logs use both reasoning and assistant.reasoning + // across CLI/runtime versions, so count both as reasoning events. + else if (eventType === "reasoning" || eventType === "assistant.reasoning") { + session.reasoning_events += 1; + } else if (eventType === "tool.execution_start") { + session.tool_execution_starts += 1; + } else if (eventType === "tool.execution_complete") { + session.tool_execution_completes += 1; + const data = entry.data || {}; + const success = typeof data === "object" ? data.success !== false : true; + if (!success) { + session.failed_tool_executions += 1; + } + } + } + } catch (err) { + // Skip files that can't be read + continue; + } + } + } + + return session.total_events > 0 ? session : null; +} + +/** + * Parse MCP gateway logs and aggregate tool call counts + */ +function parseGatewayLogs() { + const gateway = { total_calls: 0, failed_calls: 0, servers: {} }; + const gatewayPaths = []; + + const pathPairs = [ + ["/tmp/gh-aw/sandbox/agent/logs/mcp-logs/gateway.jsonl", "/tmp/gh-aw/sandbox/agent/logs/gateway.jsonl"], + ["/tmp/gh-aw/threat-detection/sandbox/agent/logs/mcp-logs/gateway.jsonl", "/tmp/gh-aw/threat-detection/sandbox/agent/logs/gateway.jsonl"], + ]; + + for (const [modernPath, legacyPath] of pathPairs) { + if (fs.existsSync(modernPath)) { + gatewayPaths.push(modernPath); + } else if (fs.existsSync(legacyPath)) { + gatewayPaths.push(legacyPath); + } + } + + for (const gatewayPath of gatewayPaths) { + if (!fs.existsSync(gatewayPath)) { + continue; + } + + try { + const content = fs.readFileSync(gatewayPath, "utf-8"); + const lines = content.split("\n"); + + for (const raw of lines) { + const line = raw.trim(); + if (!line || !line.startsWith("{")) { + continue; + } + + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + const event = String(entry.event || "") + .trim() + .toLowerCase(); + if (!["tool_call", "rpc_call", "request"].includes(event)) { + continue; + } + + gateway.total_calls += 1; + + const status = String(entry.status || "") + .trim() + .toLowerCase(); + const level = String(entry.level || "") + .trim() + .toLowerCase(); + const errorText = String(entry.error || "").trim(); + const failed = status === "error" || errorText !== "" || level === "error"; + + if (failed) { + gateway.failed_calls += 1; + } + + // gateway.jsonl has server_name for modern logs and server_id in + // some compatibility/transition paths; keep fallback ordering explicit. + const serverName = String(entry.server_name || entry.server_id || "unknown"); + + if (!gateway.servers[serverName]) { + gateway.servers[serverName] = { tool_call_count: 0, failed_calls: 0 }; + } + + gateway.servers[serverName].tool_call_count += 1; + if (failed) { + gateway.servers[serverName].failed_calls += 1; + } + } + } catch (err) { + // Skip files that can't be read + continue; + } + } + + if (gateway.total_calls > 0) { + return { + total_calls: gateway.total_calls, + failed_calls: gateway.failed_calls, + servers: Object.entries(gateway.servers) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([serverName, bucket]) => ({ + server_name: serverName, + tool_call_count: bucket.tool_call_count, + failed_calls: bucket.failed_calls, + })), + }; + } + + return null; +} + +/** + * Main function to generate usage activity summary + */ +function main() { + const summary = { schema: "usage-activity-summary/v1" }; + + // Parse firewall logs + const firewall = parseFirewallLogs(); + if (firewall) { + summary.firewall = firewall; + } + + // Parse session logs + const session = parseSessionLogs(); + if (session) { + summary.session = session; + } + + // Parse gateway logs + const gateway = parseGatewayLogs(); + if (gateway) { + summary.gateway = gateway; + } + + // Write summary to file + const outputPath = "/tmp/gh-aw/usage/activity/summary.json"; + fs.writeFileSync(outputPath, JSON.stringify(summary, null, 2), "utf-8"); + console.log(outputPath); +} + +// Run main function +if (require.main === module) { + main(); +} + +module.exports = { parseFirewallLogs, parseSessionLogs, parseGatewayLogs }; diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index c277bf7..08d45e4 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -16,6 +16,7 @@ const { formatAICCredits } = require("./daily_aic_workflow_helpers.cjs"); const { formatAIC } = require("./model_costs.cjs"); const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp_gateway_log.cjs"); const { readDedupedTokenUsage, TOKEN_USAGE_PATHS } = require("./parse_token_usage.cjs"); +const { extractShellCommandFromToolData } = require("./tool_call_details.cjs"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -29,6 +30,9 @@ const DEFAULT_OTEL_JSONL_PATH = "/tmp/gh-aw/otel.jsonl"; const FAILURE_CATEGORIES_PATH = "/tmp/gh-aw/failure_categories.json"; const GITHUB_API_VERSION = "2022-11-28"; const COPILOT_SESSION_STATE_DIR = path.join(os.tmpdir(), "gh-aw", "sandbox", "agent", "logs", "copilot-session-state"); +const RECENT_TOOL_CALLS_WITH_COMMAND_PREVIEW = new Set(["bash", "shell"]); +const ELLIPSIS = "..."; +const ELLIPSIS_LENGTH = ELLIPSIS.length; // Engine-side 429/rate-limit signatures: // - HTTP 429 accompanied by "too many requests"/"rate limit" phrasing // - provider error codes like rate_limit_error / rate_limit_exceeded @@ -36,6 +40,7 @@ const COPILOT_SESSION_STATE_DIR = path.join(os.tmpdir(), "gh-aw", "sandbox", "ag // - retry wrapper text that includes the canonical "Failed to get response..." phrase const ENGINE_RATE_LIMIT_429_RE = /(?:\b429\b[\s\S]{0,120}(?:too many requests|rate[\s-]*limit)|\brate_limit_(?:error|exceeded)\b|capierror:\s*429|failed to get response from the ai model[\s\S]{0,120}\b429\b|exceeded your rate limit for utility models)/i; +const ENGINE_MAX_RUNS_EXCEEDED_RE = /(?:\bmax_runs_exceeded\b|\bmaximum\s+llm\s+invocations\s+exceeded\b)/i; /** * Parse action failure issue expiration from environment. @@ -1173,6 +1178,48 @@ function normalizeDeniedPermissionCommand(command) { return cmd; } +/** + * Collapse tool call details to a compact single-line preview. + * @param {string} value + * @param {number} [maxLen] + * @returns {string} + */ +function normalizeToolCallPreview(value, maxLen = 120) { + const singleLine = String(value || "") + .replace(/`/g, "'") + .replace(/\s+/g, " ") + .trim(); + if (!singleLine) return ""; + if (singleLine.length <= maxLen) return singleLine; + return `${singleLine.slice(0, maxLen - ELLIPSIS_LENGTH)}${ELLIPSIS}`; +} + +/** + * Best-effort extraction of a shell command preview from a tool.execution_start payload. + * @param {Record} data + * @returns {string} + */ +function extractShellCommandPreview(data) { + return normalizeToolCallPreview(extractShellCommandFromToolData(data)); +} + +/** + * Format a compact display value for a recent tool call entry. + * @param {string} toolName + * @param {string} mcpServerName + * @param {Record} data + * @returns {string} + */ +function formatRecentToolCall(toolName, mcpServerName, data) { + const base = mcpServerName ? `${mcpServerName}.${toolName}` : toolName; + const normalizedToolName = typeof toolName === "string" ? toolName.toLowerCase() : ""; + if (!RECENT_TOOL_CALLS_WITH_COMMAND_PREVIEW.has(normalizedToolName)) { + return base; + } + const commandPreview = extractShellCommandPreview(data); + return commandPreview ? `${base}(${commandPreview})` : base; +} + /** * Load missing_tool messages from agent output. * Returns an empty array when the output file doesn't exist, cannot be parsed, or has no missing_tool items. @@ -1319,7 +1366,7 @@ function loadToolDenialsExceededEvents() { const toolName = typeof parsed.data.toolName === "string" ? parsed.data.toolName.trim() : ""; if (toolName) { const mcpServerName = typeof parsed.data.mcpServerName === "string" ? parsed.data.mcpServerName.trim() : ""; - recentToolCalls.push(mcpServerName ? `${mcpServerName}.${toolName}` : toolName); + recentToolCalls.push(formatRecentToolCall(toolName, mcpServerName, parsed.data)); if (recentToolCalls.length > 5) recentToolCalls.shift(); } continue; @@ -1565,6 +1612,20 @@ function hasEngineRateLimit429Signal(content) { return ENGINE_RATE_LIMIT_429_RE.test(content); } +/** + * Detect max-runs guardrail failures in text payloads. + * Returns true when content includes either the `max_runs_exceeded` error type + * or the "Maximum LLM invocations exceeded" message fragment. + * @param {string|null|undefined} content + * @returns {boolean} + */ +function hasEngineMaxRunsExceededSignal(content) { + if (!content) { + return false; + } + return ENGINE_MAX_RUNS_EXCEEDED_RE.test(content); +} + /** * Detect HTTP 429/rate-limit engine failures from OTLP JSONL mirror payloads. * @param {string} [otelJsonlPathOverride] @@ -1593,6 +1654,17 @@ function buildEngineRateLimit429Context(engineLabel) { return "\n" + renderPromptTemplate("engine_rate_limit_429.md", { engine_label: normalizedEngineLabel }); } +/** + * Build dedicated context for max-runs guardrail failures. + * Renders the max-runs-exceeded prompt template with the active engine label. + * @param {string} [engineLabel] + * @returns {string} + */ +function buildEngineMaxRunsExceededContext(engineLabel) { + const normalizedEngineLabel = (typeof engineLabel === "string" ? engineLabel : "").trim() || "AI"; + return "\n" + renderPromptTemplate("engine_max_runs_exceeded.md", { engine_label: normalizedEngineLabel }); +} + /** * Read and render token usage from token-usage.jsonl for inclusion in the ET computation table. * Returns null gracefully when files are absent, empty, or unparseable. @@ -2092,6 +2164,11 @@ function buildEngineFailureContext(options = {}) { return buildEngineRateLimit429Context(engineLabel); } + if (hasEngineMaxRunsExceededSignal(logContent)) { + core.info("Detected engine max-runs guardrail signal — using dedicated context message"); + return buildEngineMaxRunsExceededContext(engineLabel); + } + const errorMessages = new Set(); for (const line of lines) { @@ -3456,8 +3533,10 @@ module.exports = { buildCredentialAuthErrorContext, buildAICreditsRateLimitErrorContext, buildUnknownModelAICreditsContext, + hasEngineMaxRunsExceededSignal, hasEngineRateLimit429Signal, hasEngineRateLimit429InOTELMirror, + buildEngineMaxRunsExceededContext, buildEngineRateLimit429Context, readTokenUsageMarkdown, parseFirewallAuthErrors, diff --git a/setup/js/mcp_cli_bridge.cjs b/setup/js/mcp_cli_bridge.cjs index ac27ed4..bdd2547 100644 --- a/setup/js/mcp_cli_bridge.cjs +++ b/setup/js/mcp_cli_bridge.cjs @@ -481,13 +481,16 @@ function parseBridgeArgs(argv) { } /** - * Check whether stdin should be read and parsed as a JSON payload for tool arguments. - * Returns true when the '.' sentinel is the only argument, or when no arguments are - * provided and stdin is not connected to a terminal (i.e. data is being piped). + * Check whether stdin should be read for tool arguments. + * Returns true when: + * - The '.' sentinel is the only argument (JSON payload mode — full args from stdin), or + * - No arguments are provided and stdin is not connected to a terminal (piped JSON payload), or + * - Any '--key .' or '--key=.' pair is present (per-field stdin mode — raw text for that field). * - * This enables agents to pipe complex multi-argument payloads as a single JSON object: + * This enables agents to pipe content in multiple ways: * printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment . * printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment + * printf 'Long issue body...' | safeoutputs create_issue --title "Bug" --body . * * @param {string[]} args - User arguments after the tool name * @returns {boolean} @@ -495,6 +498,15 @@ function parseBridgeArgs(argv) { function hasStdinJsonPayload(args) { if (args.length === 1 && args[0] === ".") return true; if (args.length === 0 && !process.stdin.isTTY) return true; + // Per-field stdin marker: --key . (space-separated) or --key=. (equals-separated) + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith("--")) { + const raw = args[i].slice(2); + const eqIdx = raw.indexOf("="); + if (eqIdx >= 0 && raw.slice(eqIdx + 1) === ".") return true; + if (eqIdx < 0 && i + 1 < args.length && args[i + 1] === ".") return true; + } + } return false; } @@ -551,10 +563,16 @@ function readStdinSync() { * complex multi-argument payloads without shell quoting issues: * printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment . * + * When `stdinContent` is provided and non-empty, any '--key .' or '--key=.' + * pair substitutes that field's value with the raw stdin text (per-field + * stdin mode). This enables agents to pipe large text into a single field: + * printf 'Long issue body...' | safeoutputs create_issue --title "Bug" --body . + * When stdin is empty, the '.' is passed through as a literal value. + * * @param {string[]} args - User arguments after the tool name * @param {Record} [schemaProperties] - Tool input schema properties - * @param {string | null} [stdinContent] - Pre-read stdin content; used only when args is empty - * or `['.']` (JSON payload mode). Ignored for all other argument forms. + * @param {string | null} [stdinContent] - Pre-read stdin content; used in JSON payload mode + * (args empty or `['.']`) and per-field stdin mode (`--key .`). * @returns {{args: Record, json: boolean}} */ function parseToolArgs(args, schemaProperties = {}, stdinContent = null) { @@ -563,14 +581,15 @@ function parseToolArgs(args, schemaProperties = {}, stdinContent = null) { let jsonOutput = false; const hasSchemaProperties = Object.keys(schemaProperties).length > 0; const { normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys } = buildNormalizedSchemaKeyMap(schemaProperties); + // Trimmed stdin content used in both JSON payload mode and per-field stdin mode. + const trimmedStdin = stdinContent !== null ? stdinContent.trim() : null; // JSON payload mode: when args is empty or ['.'] and stdinContent is available, // parse stdin as a JSON object and use its properties directly as tool arguments. - if (stdinContent !== null && (args.length === 0 || (args.length === 1 && args[0] === "."))) { - const trimmed = stdinContent.trim(); - if (trimmed) { + if (trimmedStdin !== null && (args.length === 0 || (args.length === 1 && args[0] === "."))) { + if (trimmedStdin) { try { - const parsed = JSON.parse(trimmed); + const parsed = JSON.parse(trimmedStdin); if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { for (const [key, value] of Object.entries(parsed)) { const canonicalKey = resolveSchemaPropertyKey(key, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys); @@ -596,14 +615,22 @@ function parseToolArgs(args, schemaProperties = {}, stdinContent = null) { } else { const canonicalKey = resolveSchemaPropertyKey(key, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys); const rawValue = raw.slice(eqIdx + 1); - result[canonicalKey] = coerceToolArgValue(canonicalKey, rawValue, schemaProperties[canonicalKey], result[canonicalKey], !hasSchemaProperties); + if (rawValue === "." && trimmedStdin) { + result[canonicalKey] = trimmedStdin; + } else { + result[canonicalKey] = coerceToolArgValue(canonicalKey, rawValue, schemaProperties[canonicalKey], result[canonicalKey], !hasSchemaProperties); + } } } else if (raw === "json") { jsonOutput = true; } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) { const canonicalKey = resolveSchemaPropertyKey(raw, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys); const rawValue = args[i + 1]; - result[canonicalKey] = coerceToolArgValue(canonicalKey, rawValue, schemaProperties[canonicalKey], result[canonicalKey], !hasSchemaProperties); + if (rawValue === "." && trimmedStdin) { + result[canonicalKey] = trimmedStdin; + } else { + result[canonicalKey] = coerceToolArgValue(canonicalKey, rawValue, schemaProperties[canonicalKey], result[canonicalKey], !hasSchemaProperties); + } i++; } else { const canonicalKey = resolveSchemaPropertyKey(raw, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys); diff --git a/setup/js/mcp_scripts_validation.cjs b/setup/js/mcp_scripts_validation.cjs index 2f76f2b..fd663c3 100644 --- a/setup/js/mcp_scripts_validation.cjs +++ b/setup/js/mcp_scripts_validation.cjs @@ -73,8 +73,38 @@ function validateStringInputLengths(args, inputSchema, maxBytes) { return violations; } +/** + * Validate that string-typed arguments meet the schema's minLength constraints. + * Trims values before comparing (matching downstream validator behavior). + * Only checks top-level properties with `type === "string"` and an explicit `minLength`. + * Absent or non-string values are skipped. + * + * @param {Object} args - The arguments object to validate + * @param {Object} inputSchema - The input schema describing property types and constraints + * @returns {{ field: string, minLength: number, actualLength: number }[]} Array of violations (empty if all OK) + */ +function validateStringMinLengths(args, inputSchema) { + const properties = inputSchema && inputSchema.properties ? inputSchema.properties : {}; + const violations = []; + + for (const [field, schema] of Object.entries(properties)) { + if (schema && schema.type === "string" && typeof schema.minLength === "number") { + const value = args[field]; + if (typeof value === "string") { + const trimmedLength = value.trim().length; + if (trimmedLength < schema.minLength) { + violations.push({ field, minLength: schema.minLength, actualLength: trimmedLength }); + } + } + } + } + + return violations; +} + module.exports = { validateRequiredFields, validateStringInputLengths, + validateStringMinLengths, MAX_STRING_INPUT_BYTES, }; diff --git a/setup/js/mcp_server_core.cjs b/setup/js/mcp_server_core.cjs index ca51dc2..7b707a2 100644 --- a/setup/js/mcp_server_core.cjs +++ b/setup/js/mcp_server_core.cjs @@ -31,7 +31,7 @@ const fs = require("fs"); const path = require("path"); const { ReadBuffer } = require("./read_buffer.cjs"); -const { validateRequiredFields, validateStringInputLengths } = require("./mcp_scripts_validation.cjs"); +const { validateRequiredFields, validateStringInputLengths, validateStringMinLengths } = require("./mcp_scripts_validation.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { generateEnhancedErrorMessage } = require("./mcp_enhanced_errors.cjs"); const { createDependencyInstallGate } = require("./mcp_dependencies_manager.cjs"); @@ -772,9 +772,10 @@ async function handleRequest(server, request, defaultHandler) { if (missing.length) { const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0; if (hasRequiredFields && Object.keys(args).length === 0) { + const schemaGuidance = generateEnhancedErrorMessage(tool.inputSchema.required, name, tool.inputSchema); throw { code: -32602, - message: `Empty arguments are not allowed — this tool is write-once, not a discovery probe. To inspect the schema, use the tools/list MCP method. To signal that no action is needed, call \`noop\` with a \`message\`.`, + message: `Empty arguments are not allowed — this tool is write-once, not a discovery probe. To inspect the schema, use the tools/list MCP method. To signal that no action is needed, call \`noop\` with a \`message\`.\n\n${schemaGuidance}`, }; } throw { @@ -793,6 +794,16 @@ async function handleRequest(server, request, defaultHandler) { }; } + // Validate minLength constraints from the schema. + const tooShort = validateStringMinLengths(args, tool.inputSchema); + if (tooShort.length) { + const details = tooShort.map(v => `'${v.field}' is too short (minimum ${v.minLength} characters, got ${v.actualLength})`).join(", "); + throw { + code: -32602, + message: `Invalid arguments: ${details}`, + }; + } + // Call handler and await the result (supports both sync and async handlers) const handlerResult = await Promise.resolve(handler(args)); const content = handlerResult && handlerResult.content ? handlerResult.content : []; @@ -931,10 +942,11 @@ async function handleMessage(server, req, defaultHandler) { if (missing.length) { const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0; if (hasRequiredFields && Object.keys(args).length === 0) { + const schemaGuidance = generateEnhancedErrorMessage(tool.inputSchema.required, name, tool.inputSchema); server.replyError( id, -32602, - `Empty arguments are not allowed — this tool is write-once, not a discovery probe. To inspect the schema, use the tools/list MCP method. To signal that no action is needed, call \`noop\` with a \`message\`.` + `Empty arguments are not allowed — this tool is write-once, not a discovery probe. To inspect the schema, use the tools/list MCP method. To signal that no action is needed, call \`noop\` with a \`message\`.\n\n${schemaGuidance}` ); return; } @@ -950,6 +962,14 @@ async function handleMessage(server, req, defaultHandler) { return; } + // Validate minLength constraints from the schema. + const tooShort = validateStringMinLengths(args, tool.inputSchema); + if (tooShort.length) { + const details = tooShort.map(v => `'${v.field}' is too short (minimum ${v.minLength} characters, got ${v.actualLength})`).join(", "); + server.replyError(id, -32602, `Invalid arguments: ${details}`); + return; + } + // Call handler and await the result (supports both sync and async handlers) server.debug(`Calling handler for tool: ${name}`); const result = await Promise.resolve(handler(args)); diff --git a/setup/js/models.json b/setup/js/models.json index 78e0119..9155b93 100644 --- a/setup/js/models.json +++ b/setup/js/models.json @@ -695,6 +695,16 @@ "gpt-image-1.5": { "cost": {} }, + "gpt-image-2": { + "cost": {} + }, + "gpt-5-search-api": { + "cost": { + "input": "1.25e-06", + "output": "1e-05", + "cache_read": "1.25e-07" + } + }, "gpt-4.1-nano": { "cost": { "input": "1.0000000000000001e-07", diff --git a/setup/js/parse_threat_detection_results.cjs b/setup/js/parse_threat_detection_results.cjs index 5bf98cb..747f640 100644 --- a/setup/js/parse_threat_detection_results.cjs +++ b/setup/js/parse_threat_detection_results.cjs @@ -142,6 +142,24 @@ function extractFromStreamJson(line) { return extractResultFromText(text.slice(prefixIdx)); } + /** + * @param {unknown} content + * @returns {string|null} + */ + function extractFromAssistantContent(content) { + if (typeof content === "string") { + return extractPrefixedResult(content) || extractStructuredOutput(content); + } + if (!Array.isArray(content)) return null; + for (const part of content) { + const text = part && typeof part === "object" && typeof part.text === "string" ? part.text : null; + if (!text) continue; + const extracted = extractPrefixedResult(text) || extractStructuredOutput(text); + if (extracted) return extracted; + } + return null; + } + try { const obj = JSON.parse(jsonText); // Only extract from the authoritative "result" summary, not "assistant" messages. @@ -187,6 +205,25 @@ function extractFromStreamJson(line) { if (obj.type === "item.completed" && obj.item && typeof obj.item.text === "string") { return extractPrefixedResult(obj.item.text) || extractStructuredOutput(obj.item.text); } + + // Pi emits final assistant verdicts in turn_end/message_end envelopes. + if ((obj.type === "turn_end" || obj.type === "message_end") && obj.message && obj.message.role === "assistant") { + return extractFromAssistantContent(obj.message.content); + } + + // Some streams emit final assistant state via message_update. + if (obj.type === "message_update" && obj.message && obj.message.role === "assistant") { + return extractFromAssistantContent(obj.message.content); + } + + // Agent-level summaries may include all messages; parse assistant messages only. + if (obj.type === "agent_end" && Array.isArray(obj.messages)) { + for (const message of obj.messages) { + if (!message || message.role !== "assistant") continue; + const extracted = extractFromAssistantContent(message.content); + if (extracted) return extracted; + } + } } catch { // Not valid JSON — not a stream-json line } diff --git a/setup/js/resolve_mentions_from_payload.cjs b/setup/js/resolve_mentions_from_payload.cjs index 4195a93..a81a5e7 100644 --- a/setup/js/resolve_mentions_from_payload.cjs +++ b/setup/js/resolve_mentions_from_payload.cjs @@ -101,6 +101,70 @@ function extractKnownAuthorsFromPayload(context) { return users; } +/** + * Fetch members of a GitHub team and return their logins. + * Accepts "team-slug" (resolved against the current org) or "org/team-slug" format. + * Failures are non-fatal: a warning is logged and an empty array is returned. + * @param {string} teamEntry - Team identifier, e.g. "my-team" or "myorg/my-team" + * @param {string} defaultOrg - The org to use when no org is specified in teamEntry + * @param {any} github - GitHub API client + * @param {any} core - GitHub Actions core + * @returns {Promise} Array of member logins (non-bot), empty on any failure + */ +async function fetchTeamMembers(teamEntry, defaultOrg, github, core) { + let org = defaultOrg; + let teamSlug = teamEntry; + + // Support "org/team-slug" format + const slashIdx = teamEntry.indexOf("/"); + if (slashIdx !== -1) { + org = teamEntry.slice(0, slashIdx); + teamSlug = teamEntry.slice(slashIdx + 1); + } + + if (!org || !teamSlug) { + core.warning(`[MENTIONS] Skipping invalid team entry: "${teamEntry}"`); + return []; + } + + try { + const logins = /** @type {string[]} */ []; + let page = 1; + const maxPages = 10; // cap at 1000 members to avoid excessive API calls + + while (page <= maxPages) { + const response = await github.rest.teams.listMembersInOrg({ + org, + team_slug: teamSlug, + per_page: 100, + page, + }); + const pageLogins = response.data.filter(member => member.type !== "Bot" && typeof member.login === "string").map(member => member.login); + logins.push(...pageLogins); + if (response.data.length < 100) { + break; // no more pages + } + page++; + } + + core.info(`[MENTIONS] Fetched ${logins.length} member(s) from team ${org}/${teamSlug}`); + return logins; + } catch (error) { + const status = /** @type {any} */ error?.status; + const isRateLimit = status === 429 || (status === 403 && /rate.?limit/i.test(getErrorMessage(error))); + const isPermission = !isRateLimit && (status === 403 || status === 404); + + if (isRateLimit) { + core.warning(`[MENTIONS] Rate limit reached while fetching team ${org}/${teamSlug} members - skipping team (retry later or reduce team count)`); + } else if (isPermission) { + core.warning(`[MENTIONS] Cannot access team ${org}/${teamSlug} (HTTP ${status}) - ensure the token has 'read:org' scope and the team exists`); + } else { + core.warning(`[MENTIONS] Failed to fetch members for team ${org}/${teamSlug}: ${getErrorMessage(error)}`); + } + return []; + } +} + /** * Resolve allowed mentions from the current GitHub event context * @param {any} context - GitHub Actions context @@ -123,9 +187,10 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions } // Get configuration options (with defaults) - const allowTeamMembers = mentionsConfig?.allowTeamMembers !== false; // default: true + const allowCollaboratorMentions = (mentionsConfig?.allowedCollaborators ?? mentionsConfig?.allowTeamMembers) !== false; // default: true const allowContext = mentionsConfig?.allowContext !== false; // default: true const allowedList = mentionsConfig?.allowed || []; + const allowedTeams = mentionsConfig?.allowedTeams || []; const maxMentions = mentionsConfig?.max || 50; try { @@ -137,6 +202,17 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions knownAuthors.push(...allowedList.filter(alias => typeof alias === "string" && alias.length > 0)); } + // Add members from allowed-teams (always included regardless of collaborator mention setting) + if (Array.isArray(allowedTeams) && allowedTeams.length > 0) { + core.info(`[MENTIONS] Fetching members for ${allowedTeams.length} configured team(s)`); + for (const teamEntry of allowedTeams) { + if (typeof teamEntry === "string" && teamEntry.length > 0) { + const teamMembers = await fetchTeamMembers(teamEntry, owner, github, core); + knownAuthors.push(...teamMembers); + } + } + } + // Add extra known authors (e.g. pre-fetched target issue authors for explicit item_number) if (extraKnownAuthors && extraKnownAuthors.length > 0) { core.info(`[MENTIONS] Adding ${extraKnownAuthors.length} extra known author(s): ${extraKnownAuthors.join(", ")}`); @@ -155,9 +231,9 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions deduplicatedKnownAuthors.push(author); } - // If allow-team-members is disabled, only use known authors (context + allowed list) - if (!allowTeamMembers) { - core.info(`[MENTIONS] Team members disabled - only allowing context (${deduplicatedKnownAuthors.length} users)`); + // If collaborator mentions are disabled, only use known authors (context + allowed list) + if (!allowCollaboratorMentions) { + core.info(`[MENTIONS] Collaborator mentions disabled - only allowing context (${deduplicatedKnownAuthors.length} users)`); if (deduplicatedKnownAuthors.length > maxMentions) { core.warning(`[MENTIONS] Mention limit exceeded: ${deduplicatedKnownAuthors.length} mentions, limiting to ${maxMentions}`); } @@ -192,6 +268,7 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions module.exports = { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, + fetchTeamMembers, pushNonBotUser, pushNonBotAssignees, }; diff --git a/setup/js/route_slash_command.cjs b/setup/js/route_slash_command.cjs index ab9704f..afed0e7 100644 --- a/setup/js/route_slash_command.cjs +++ b/setup/js/route_slash_command.cjs @@ -1,8 +1,10 @@ // @ts-check /// +// @safe-outputs-exempt SEC-004 — event body text is read only for slash-command parsing; outbound /help comments are built from internal metadata. const { REACTION_MAP } = require("./add_reaction.cjs"); const nodePath = require("node:path"); +const { matchesCommandName, parseSlashCommand } = require("./slash_command_matcher.cjs"); // Keep this aligned with the current default stable GitHub REST API version used by workflows. // Update when GitHub advances the recommended version to avoid sunset/deprecation warnings. const GITHUB_API_VERSION = "2022-11-28"; @@ -38,19 +40,6 @@ async function appendRoutingSummary(existingCommands, selectedCommand) { } } -/** - * Extracts the slash command name from the start of the given body text. - * Returns an empty string if the text does not begin with a valid slash command. - * A valid slash command starts with '/' followed by a name of one or more characters - * from [a-zA-Z0-9], [-], and [_]. - * @param {string} text - * @returns {string} - */ -function parseSlashCommand(text) { - const match = /^\/([a-zA-Z0-9][a-zA-Z0-9\-_]*)\b/.exec(String(text).trim()); - return match ? match[1] : ""; -} - function eventIdentifier() { if (context.eventName !== "issue_comment") { return context.eventName; @@ -310,6 +299,173 @@ async function dispatchWorkflow(workflowId, ref, inputs) { } } +function isBuiltinHelpEnabled() { + const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); + if (!raw || raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + core.warning(`Invalid value for GH_AW_HELP_COMMAND_ENABLED (expected 'true' or 'false', got '${raw}'). Using default: enabled.`); + return true; +} + +function parseHelpCommandsMetadata() { + const raw = process.env.GH_AW_HELP_COMMANDS || "[]"; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .flatMap(item => { + const command = typeof item?.command === "string" ? item.command.trim() : ""; + if (!command) { + return []; + } + const description = typeof item?.description === "string" ? item.description.trim() : ""; + return [ + { + command, + description, + centralized: Boolean(item?.centralized), + decentralized: Boolean(item?.decentralized), + label: Boolean(item?.label), + source_file: typeof item?.source_file === "string" ? item.source_file.trim() : "", + }, + ]; + }) + .sort((left, right) => left.command.localeCompare(right.command)); + } catch (error) { + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + return []; + } +} + +/** + * Regex matching bare GitHub @mentions outside inline code spans. + * Captures the preceding non-word character (p1) and the username (p2). + */ +const GITHUB_MENTION_RE = /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?)/g; + +/** + * Neutralizes bare @mentions in a description string so they do not trigger + * GitHub notifications. Wraps matched mentions in backticks. + * @param {string} description + * @returns {string} + */ +function neutralizeDescriptionMentions(description) { + return description.replace(GITHUB_MENTION_RE, (_, p1, p2) => `${p1}\`@${p2}\``); +} + +function buildCommandBulletLine(entry) { + const desc = entry.description ? neutralizeDescriptionMentions(entry.description) : ""; + const suffix = desc ? ` — ${desc}` : ""; + const commandText = `\`/${entry.command}\``; + if (entry.source_file) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const sourceUrl = `${githubServer}/${owner}/${repo}/blob/HEAD/.github/workflows/${entry.source_file}.md`; + return `- [${commandText}](${sourceUrl})${suffix}`; + } + return `- ${commandText}${suffix}`; +} + +function buildLabelBulletLine(entry) { + const desc = entry.description ? neutralizeDescriptionMentions(entry.description) : ""; + const suffix = desc ? ` — ${desc}` : ""; + return `- \`${entry.command}\`${suffix}`; +} + +function buildHelpCommentBody(helpCommands) { + // Commands that are centralized should appear only in the centralized section even if + // they are also registered as decentralized (e.g. two workflows for the same command). + const centralized = helpCommands.filter(entry => entry.centralized); + const centralizedNames = new Set(centralized.map(entry => entry.command)); + const decentralized = helpCommands.filter(entry => entry.decentralized && !centralizedNames.has(entry.command)); + const labels = helpCommands.filter(entry => entry.label); + + const lines = ["### Agentic Workflow Commands", "", "**Centralized slash commands**"]; + if (centralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of centralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", "**Non-centralized slash commands**"); + if (decentralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of decentralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", "**Label commands**"); + if (labels.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of labels) { + lines.push(buildLabelBulletLine(entry)); + } + } + + const docsUrl = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); + if (docsUrl) { + lines.push("", `Learn more: [Slash command documentation](${docsUrl})`); + } + return lines.join("\n"); +} + +async function postBuiltinHelpComment(commentBody) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + try { + const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; + if (issueNumber) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + headers: { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }); + return true; + } + + if (context.eventName === "discussion" || context.eventName === "discussion_comment") { + const discussionID = context.payload?.discussion?.node_id; + if (!discussionID) { + core.warning("Unable to post builtin /help response: discussion node_id missing."); + return false; + } + await github.graphql( + ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { id } + } + }`, + { discussionId: discussionID, body: commentBody } + ); + return true; + } + + 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)}`); + return false; + } +} + function toWorkflowDispatchID(route) { if (!route?.workflow || typeof route.workflow !== "string" || !route.workflow.trim()) { return ""; @@ -336,6 +492,34 @@ function isDisabledWorkflowDispatchError(error) { return message.includes("workflow is disabled") || message.includes("workflow was disabled") || message.includes("disabled workflow"); } +/** + * @param {Record>} slashRouteMap + * @param {string} actualCommand + * @returns {Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown}>} + */ +function resolveMatchingSlashRoutes(slashRouteMap, actualCommand) { + /** @type {Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown}>} */ + const matchedRoutes = []; + const seen = new Set(); + + for (const [configuredCommand, configuredRoutes] of Object.entries(slashRouteMap)) { + if (!matchesCommandName(configuredCommand, actualCommand) || !Array.isArray(configuredRoutes)) { + continue; + } + + for (const route of configuredRoutes) { + const key = JSON.stringify([route?.workflow ?? "", route?.ai_reaction ?? "", Array.isArray(route?.events) ? route.events : []]); + if (seen.has(key)) { + continue; + } + seen.add(key); + matchedRoutes.push(route); + } + } + + return matchedRoutes; +} + async function main() { core.info("Starting centralized command routing."); core.info(`Incoming event name: '${context.eventName}'.`); @@ -407,8 +591,21 @@ async function main() { } const commandName = selectedCommand; + if (commandName === "help") { + if (isBuiltinHelpEnabled()) { + await addImmediateReaction("eyes"); + const posted = await postBuiltinHelpComment(buildHelpCommentBody(parseHelpCommandsMetadata())); + if (posted) { + core.info("Posted builtin /help command response."); + } + return; + } + // Builtin /help is disabled — fall through so custom /help workflows still dispatch. + core.info("Builtin /help command is disabled by aw.json (help_command=false); routing normally."); + } + core.info(`Resolved command '/${commandName}' for event identifier '${identifier}'.`); - const configuredRoutes = slashRouteMap[commandName] ?? []; + const configuredRoutes = resolveMatchingSlashRoutes(slashRouteMap, commandName); core.info(`Configured routes for '/${commandName}': ${configuredRoutes.length}.`); const routes = configuredRoutes.filter(route => Array.isArray(route.events) && route.events.includes(identifier)); if (routes.length === 0) { diff --git a/setup/js/run_operation_update_upgrade.cjs b/setup/js/run_operation_update_upgrade.cjs index 8b66d75..9d8d27d 100644 --- a/setup/js/run_operation_update_upgrade.cjs +++ b/setup/js/run_operation_update_upgrade.cjs @@ -1,5 +1,6 @@ // @ts-check /// +// @safe-outputs-exempt SEC-004 — PR/issue body values in this handler are static internal templates (plus allowlisted changed-file paths), not untrusted user content. const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_CONFIG, ERR_SYSTEM } = require("./error_codes.cjs"); @@ -217,4 +218,161 @@ After merging this PR, **recompile the lock files** using one of these methods: .write(); } -module.exports = { main, formatTimestamp }; +/** + * XML marker embedded in issue bodies to identify issues created by the + * agentic-auto-upgrade workflow. Used for deduplication: old matching issues + * are closed before a new one is opened. + */ +const AUTO_UPGRADE_WORKFLOW_ID = "agentic-auto-upgrade"; +const AUTO_UPGRADE_ISSUE_MARKER = ``; + +/** + * Run the upgrade operation in notification mode: executes `gh aw upgrade`, + * detects any changed files, then creates a GitHub issue to announce that an + * upgrade is available. Before opening the new issue, any previously opened + * issues carrying the same XML marker are closed so there is never more than + * one open notification at a time. + * + * Permissions required: issues: write only (no contents/pull-requests write). + * + * Required environment variables: + * GH_TOKEN - GitHub token for gh CLI auth and GitHub API + * GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release) + * + * @returns {Promise} + */ +async function mainNotifyIssue() { + const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; + const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); + + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Run gh aw upgrade to apply changes locally + const fullCmd = [bin, ...prefixArgs, "upgrade"].join(" "); + core.info(`Running: ${fullCmd}`); + const exitCode = await exec.exec(bin, [...prefixArgs, "upgrade"]); + if (exitCode !== 0) { + throw new Error(`${ERR_SYSTEM}: Command '${fullCmd}' failed with exit code ${exitCode}`); + } + + // Detect which known upgrade files were modified + const changedFiles = []; + for (const file of KNOWN_FILES_UPGRADE) { + try { + const { stdout } = await exec.getExecOutput("git", ["diff", "--name-only", "--", file], { silent: true }); + if (stdout.trim()) { + changedFiles.push(file); + } + } catch { + // file not in repo - skip + } + } + + if (changedFiles.length === 0) { + core.info("✓ No upgrade available - agentic workflows are already up to date"); + return; + } + + core.info(`Upgrade available. Changed files (${changedFiles.length}):`); + for (const f of changedFiles) { + core.info(` ${f}`); + } + + // Discard local changes — we only notify via issue, not push + try { + await exec.exec("git", ["checkout", "--", "."]); + } catch (error) { + core.warning(`Failed to discard local changes: ${getErrorMessage(error)}`); + } + + // Close any existing open issues with the auto-upgrade XML marker. + // Strip the comment delimiters to get the plain text used in search. + const markerContent = AUTO_UPGRADE_ISSUE_MARKER.replace(/^$/, ""); + const searchQuery = `repo:${owner}/${repo} is:issue is:open "${markerContent}" in:body`; + core.info(`Searching for existing auto-upgrade issues: ${searchQuery}`); + + let existingIssues = []; + try { + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 20, + }); + existingIssues = (searchResult.data.items || []).filter(item => !item.pull_request && item.body && item.body.includes(AUTO_UPGRADE_ISSUE_MARKER)); + } catch (error) { + core.warning(`Failed to search for existing issues: ${getErrorMessage(error)}`); + } + + core.info(`Found ${existingIssues.length} existing auto-upgrade issue(s) to close`); + for (const issue of existingIssues) { + try { + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + core.info(` Closed #${issue.number}: ${issue.title}`); + } catch (error) { + core.warning(` Failed to close #${issue.number}: ${getErrorMessage(error)}`); + } + } + + // Build and create the new upgrade notification issue + const issueTitle = "[aw] Upgrade available"; + const fileList = changedFiles.map(f => `- \`${f}\``).join("\n"); + const issueBody = `## Agentic Workflow Upgrade Available + +A new version of the agentic workflow tooling is available. Run \`gh aw upgrade\` to apply it. + +**Files that will be updated:** + +${fileList} + +### How to apply + +- **Via @copilot**: Add a comment \`@copilot upgrade agentic workflows\` on this issue +- **Via CLI**: Run \`gh aw upgrade\` in your local checkout + +${AUTO_UPGRADE_ISSUE_MARKER} +`; + + core.info(`Creating upgrade notification issue: "${issueTitle}"`); + let createdIssue; + try { + createdIssue = await github.rest.issues.create({ + owner, + repo, + title: issueTitle, + body: issueBody, + labels: ["agentic-workflows"], + }); + } catch (error) { + // Label may not exist when auto-upgrade is used without maintenance label creation. + if (error?.status === 422) { + core.warning("Failed to create issue with label 'agentic-workflows'; retrying without labels"); + createdIssue = await github.rest.issues.create({ + owner, + repo, + title: issueTitle, + body: issueBody, + }); + } else { + throw error; + } + } + + const issueUrl = createdIssue.data.html_url; + core.info(`✓ Created issue: ${issueUrl}`); + core.notice(`Created upgrade notification issue: ${issueUrl}`); + + await core.summary + .addHeading(issueTitle, 2) + .addRaw(`Issue created: [${issueUrl}](${issueUrl})\n\n`) + .addRaw(`**Files that will be updated:**\n\n${fileList}\n\n`) + .addRaw(`> **To apply:** run \`gh aw upgrade\` locally or comment \`@copilot upgrade agentic workflows\`.`) + .write(); +} + +module.exports = { main, mainNotifyIssue, formatTimestamp }; diff --git a/setup/js/safe_output_type_validator.cjs b/setup/js/safe_output_type_validator.cjs index 7f47cb7..1b9d80a 100644 --- a/setup/js/safe_output_type_validator.cjs +++ b/setup/js/safe_output_type_validator.cjs @@ -67,6 +67,7 @@ function normalizeIssueClosingKeywordBackticks(content) { * @typedef {Object} FieldValidation * @property {boolean} [required] - Whether the field is required * @property {string} [type] - Expected type: 'string', 'number', 'boolean', 'array' + * @property {string} [typeHint] - Overrides the type description in error messages (e.g. "GraphQL node ID string") * @property {boolean} [sanitize] - Whether to sanitize string content * @property {number} [maxLength] - Maximum length for strings * @property {number} [minLength] - Minimum length for strings @@ -301,7 +302,7 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) // Handle required check for other fields if (validation.required && (value === undefined || value === null)) { - const fieldType = validation.type || "string"; + const fieldType = validation.typeHint || validation.type || "string"; return { isValid: false, error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, @@ -328,9 +329,10 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) if (typeof value !== "string") { // For required fields, use "requires a" format for both missing and wrong type if (validation.required) { + const fieldType = validation.typeHint || "string"; return { isValid: false, - error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (string)`, + error: `Line ${lineNum}: ${itemType} requires a '${fieldName}' field (${fieldType})`, }; } return { diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index 846041e..8104a73 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -203,6 +203,80 @@ function resolvePatchWorkspacePath(workspacePath) { function createHandlers(server, appendSafeOutput, config = {}) { const TOKEN_THRESHOLD = 16000; + /** + * Session-scoped per-type operation counters. + * Incremented on every successful appendSafeOutput call (MCE4 dual enforcement). + * @type {Map} + */ + const operationCounts = new Map(); + + /** + * Return the explicitly user-configured max for a safe-output type, or null if not set / unlimited. + * Uses getSafeOutputsToolConfig for consistent key-normalisation (hyphens → underscores). + * Does NOT fall back to validation-config defaults: MCP-time enforcement is only + * applied when the user has explicitly set a limit; downstream enforcement covers defaults. + * Per Safe Outputs Specification MCE5: the same config source as the processor. + * @param {string} type - normalised safe-output type name (e.g. "add_comment") + * @returns {number | null} + */ + function getExplicitMax(type) { + const toolConfig = getSafeOutputsToolConfig(config, type); + if (!toolConfig || typeof toolConfig !== "object") return null; + if (!("max" in toolConfig)) return null; + const maxVal = toolConfig.max; + if (maxVal === -1) return null; // -1 means unlimited + if (typeof maxVal === "number" && Number.isInteger(maxVal) && maxVal > 0) { + return maxVal; + } + return null; + } + + /** + * Enforce the per-type operation count limit at invocation time. + * Throws a JSON-RPC -32602 error when the configured max has already been reached. + * Per Safe Outputs Specification MCE4: Dual Enforcement — constraints MUST be + * enforced at both invocation time (MCP server) and processing time (safe output + * processor) to provide defence-in-depth. + * @param {string} type - normalised safe-output type name + */ + function enforcePerTypeMax(type) { + const maxAllowed = getExplicitMax(type); + if (maxAllowed === null) return; // no explicit limit configured + const current = operationCounts.get(type) || 0; + if (current >= maxAllowed) { + throw { + code: -32602, + message: `E002: ${type} limit reached — ${current} of ${maxAllowed} already used this run`, + data: { + constraint: "max", + type, + limit: maxAllowed, + guidance: + `You have used all ${maxAllowed} ${type} operations for this run. ` + + `Further ${type} calls will be ignored. Prioritize the most important items ` + + `(e.g. consolidate multiple updates into one), or call noop. ` + + `Note: other safe-output types have independent budgets, so applying one type ` + + `without its companion type can leave inconsistent state.`, + }, + }; + } + } + + /** + * Append a safe-output entry after enforcing the per-type max count. + * Increments the session counter only after a successful write, mirroring the + * approach used by inlineReviewCommentCount so that write errors do not advance + * the counter. + * Per Safe Outputs Specification MCE4: invocation-time half of dual enforcement. + * @param {Record} entry + */ + const appendSafeOutputCounted = entry => { + const type = entry?.type; + if (type) enforcePerTypeMax(type); + appendSafeOutput(entry); + if (type) operationCounts.set(type, (operationCounts.get(type) || 0) + 1); + }; + /** * Validate schema-declared explicit target parameters for wildcard-target tools. * @param {Record} entry @@ -258,7 +332,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { const fileInfo = writeLargeContentToFile(largeContent); entry[largeFieldName] = `[Content too large, saved to file: ${fileInfo.filename}]`; - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ @@ -286,7 +360,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (largeContentResponse) return largeContentResponse; // Normal case - no large content - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -416,7 +490,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { targetFileName: targetFileName, }; - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ @@ -620,7 +694,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (allowEmpty) { server.debug(`allow-empty is enabled for create_pull_request - skipping patch generation`); // Append the safe output entry without generating a patch - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -834,7 +908,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { entry.base_commit = bundleResult.baseCommit; } - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -856,7 +930,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -1277,7 +1351,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { entry.base_commit = bundleResult.baseCommit; } - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -1299,7 +1373,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -1551,7 +1625,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; const largeContentResponse = maybeHandleLargeContent(droppedEntry); if (!largeContentResponse) { - appendSafeOutput(droppedEntry); + appendSafeOutputCounted(droppedEntry); } return { content: [ @@ -1572,7 +1646,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { const largeContentResponse = maybeHandleLargeContent(entry); if (largeContentResponse) return largeContentResponse; - appendSafeOutput(entry); + appendSafeOutputCounted(entry); return { content: [ { @@ -1603,7 +1677,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { server.debug(`temporary_id for create_project: ${entry.temporary_id}`); // Append to safe outputs - appendSafeOutput(entry); + appendSafeOutputCounted(entry); // Return the temporary_id to the agent so it can reference this project return { @@ -1714,7 +1788,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { server.debug(`temporary_id for add_comment: ${entry.temporary_id}`); // Append to safe outputs - appendSafeOutput(entry); + appendSafeOutputCounted(entry); // Return the temporary_id to the agent so it can reference this comment return { @@ -1893,7 +1967,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { server.debug(`upload_artifact: staged ${filePath} as ${destName}`); } - appendSafeOutput(entry); + appendSafeOutputCounted(entry); const temporaryId = entry.temporary_id || null; return { diff --git a/setup/js/sanitize_content_core.cjs b/setup/js/sanitize_content_core.cjs index 2e29f05..bcb3256 100644 --- a/setup/js/sanitize_content_core.cjs +++ b/setup/js/sanitize_content_core.cjs @@ -7,6 +7,7 @@ */ const { isRepoAllowed } = require("./repo_helpers.cjs"); +const { resolveMatchedCommand } = require("./slash_command_matcher.cjs"); const SAFE_OUTPUTS_URLS_ENV = "GH_AW_SAFE_OUTPUTS_URLS"; const SAFE_OUTPUTS_URLS_ALLOWED_ONLY = "allowed-only"; @@ -359,17 +360,26 @@ function neutralizeCommands(s) { return s; } - // Neutralize each command name at the start of text (with optional leading whitespace) - let result = s; + const leadingWhitespace = s.match(/^\s*/)?.[0] ?? ""; + const remainder = s.slice(leadingWhitespace.length); + const matchedCommand = resolveMatchedCommand(remainder, commandNames); + if (matchedCommand) { + const escapedCommand = matchedCommand.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); + } + for (const name of commandNames) { + if (name.endsWith("*")) { + continue; + } const escapedCommand = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - result = result.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); - // Stop after first substitution (only one command can be at position 0) + const result = s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); if (result !== s) { - break; + return result; } } - return result; + + return s; } /** diff --git a/setup/js/slash_command_matcher.cjs b/setup/js/slash_command_matcher.cjs new file mode 100644 index 0000000..7aa25c8 --- /dev/null +++ b/setup/js/slash_command_matcher.cjs @@ -0,0 +1,92 @@ +// @ts-check + +/** + * Extracts the slash command name from the start of the given body text. + * Returns an empty string if the text does not begin with a valid slash command. + * A valid slash command starts with '/' followed by a name of one or more characters + * from [a-zA-Z0-9], [-], [_], and [.]. + * @param {string} text + * @returns {string} + */ +function parseSlashCommand(text) { + const match = /^\/([a-zA-Z0-9][a-zA-Z0-9._-]*)(?=$|\s)/.exec(String(text).trim()); + return match ? match[1] : ""; +} + +const CATCH_ALL_COMMAND = "*"; + +/** + * @param {string} commandName + * @returns {boolean} + */ +function isWildcardCommandName(commandName) { + return typeof commandName === "string" && commandName.endsWith("*"); +} + +/** + * @param {string} commandName + * @returns {boolean} + */ +function isCatchAllCommandName(commandName) { + return commandName === CATCH_ALL_COMMAND; +} + +/** + * @param {string} configuredCommand + * @returns {string} + */ +function wildcardCommandPrefix(configuredCommand) { + return isWildcardCommandName(configuredCommand) ? configuredCommand.slice(0, -1) : ""; +} + +/** + * @param {string} configuredCommand + * @param {string} actualCommand + * @returns {boolean} + */ +function matchesCommandName(configuredCommand, actualCommand) { + if (typeof configuredCommand !== "string" || typeof actualCommand !== "string") { + return false; + } + + if (isCatchAllCommandName(configuredCommand)) { + return actualCommand !== ""; + } + + if (isWildcardCommandName(configuredCommand)) { + const prefix = wildcardCommandPrefix(configuredCommand); + return prefix !== "" && actualCommand.startsWith(prefix); + } + + return configuredCommand === actualCommand; +} + +/** + * @param {string} text + * @param {string[]} configuredCommands + * @returns {string} + */ +function resolveMatchedCommand(text, configuredCommands) { + const actualCommand = parseSlashCommand(text); + if (!actualCommand) { + return ""; + } + + for (const configuredCommand of configuredCommands) { + if (matchesCommandName(configuredCommand, actualCommand)) { + return actualCommand; + } + } + + return ""; +} + +module.exports = { + CATCH_ALL_COMMAND, + isCatchAllCommandName, + isWildcardCommandName, + matchesCommandName, + parseSlashCommand, + resolveMatchedCommand, + wildcardCommandPrefix, +}; diff --git a/setup/js/tool_call_details.cjs b/setup/js/tool_call_details.cjs new file mode 100644 index 0000000..dc51ed0 --- /dev/null +++ b/setup/js/tool_call_details.cjs @@ -0,0 +1,37 @@ +// @ts-check + +/** + * Best-effort extraction of shell command text from a tool.execution_start payload. + * @param {any} data + * @returns {string} + */ +function extractShellCommandFromToolData(data) { + if (!data || typeof data !== "object") return ""; + // Priority order prefers top-level command-like fields emitted by tool wrappers, + // then object-shaped payloads used by MCP/SDK tool schemas. + /** @type {Array} */ + const commandFieldCandidates = []; + if ("command" in data) commandFieldCandidates.push(data.command); + if ("input" in data) commandFieldCandidates.push(data.input); + if ("arguments" in data) commandFieldCandidates.push(data.arguments); + if ("args" in data) commandFieldCandidates.push(data.args); + if ("toolInput" in data) commandFieldCandidates.push(data.toolInput); + if ("parameters" in data) commandFieldCandidates.push(data.parameters); + for (const candidate of commandFieldCandidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + if (!candidate || typeof candidate !== "object") continue; + if (typeof candidate.command === "string" && candidate.command.trim()) { + return candidate.command.trim(); + } + if (typeof candidate.cmd === "string" && candidate.cmd.trim()) { + return candidate.cmd.trim(); + } + } + return ""; +} + +module.exports = { + extractShellCommandFromToolData, +}; diff --git a/setup/js/update_entity_helpers.cjs b/setup/js/update_entity_helpers.cjs new file mode 100644 index 0000000..61a353f --- /dev/null +++ b/setup/js/update_entity_helpers.cjs @@ -0,0 +1,54 @@ +// @ts-check +/// + +const { sanitizeTitle } = require("./sanitize_title.cjs"); +const { parseBoolTemplatable } = require("./templatable.cjs"); + +/** + * Build shared update payload fields for issue/PR update handlers. + * + * @param {Object} item + * @param {Object} config + * @param {Object} options + * @param {boolean} [options.allowTitle=true] + * @param {string} [options.defaultOperation] - Required when item.body may be present; used as fallback operation if item.operation and configDefaultOperation are both absent. + * @param {string | undefined} [options.configDefaultOperation] + * @param {boolean} [options.includeBodyInApiData=false] + * @param {(() => void) | undefined} [options.onBodyDisallowed] + * @returns {{updateData: Object, hasCommonUpdates: boolean}} + */ +function buildCommonEntityUpdateData(item, config, options = {}) { + const { allowTitle = true, defaultOperation, configDefaultOperation, includeBodyInApiData = false, onBodyDisallowed } = options; + + const updateData = {}; + let hasCommonUpdates = false; + + if (allowTitle && item.title !== undefined) { + updateData.title = sanitizeTitle(item.title); + hasCommonUpdates = true; + } + + const canUpdateBody = config.allow_body !== false; + if (item.body !== undefined && canUpdateBody) { + const resolvedOperation = item.operation || configDefaultOperation || defaultOperation; + if (!resolvedOperation) { + throw new Error("buildCommonEntityUpdateData: defaultOperation is required when body may be present"); + } + updateData._operation = resolvedOperation; + updateData._rawBody = item.body; + if (includeBodyInApiData) { + updateData.body = item.body; + } + hasCommonUpdates = true; + } else if (item.body !== undefined && !canUpdateBody && typeof onBodyDisallowed === "function") { + onBodyDisallowed(); + } + + // Always populate _includeFooter: downstream executeUpdate reads it regardless of + // whether title/body changed, matching pre-refactor behavior in both callers. + updateData._includeFooter = parseBoolTemplatable(config.footer, true); + + return { updateData, hasCommonUpdates }; +} + +module.exports = { buildCommonEntityUpdateData }; diff --git a/setup/js/update_issue.cjs b/setup/js/update_issue.cjs index 5abe7f1..a101b36 100644 --- a/setup/js/update_issue.cjs +++ b/setup/js/update_issue.cjs @@ -11,11 +11,10 @@ const HANDLER_TYPE = "update_issue"; const { resolveTarget, checkRequiredFilter } = require("./safe_output_helpers.cjs"); const { createUpdateHandlerFactory, createStandardResolveNumber, createStandardFormatResult } = require("./update_handler_factory.cjs"); const { updateBody } = require("./update_pr_description_helpers.cjs"); +const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs"); const { loadTemporaryProjectMap, replaceTemporaryProjectReferences } = require("./temporary_id.cjs"); -const { sanitizeTitle } = require("./sanitize_title.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); -const { parseBoolTemplatable } = require("./templatable.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { fetchIssueState, mergeIssueState } = require("./safe_output_execution_metadata.cjs"); @@ -131,23 +130,15 @@ const resolveIssueNumber = createStandardResolveNumber({ * @returns {{success: true, data: Object} | {success: false, error: string}} Update data result */ function buildIssueUpdateData(item, config) { - const updateData = {}; + // hasCommonUpdates is not needed here: the issue handler always continues to check + // entity-specific fields (state, labels, assignees, milestone, title prefix). + const { updateData } = buildCommonEntityUpdateData(item, config, { + defaultOperation: "append", + onBodyDisallowed: () => { + core.warning("Body update not allowed by safe-outputs configuration"); + }, + }); - if (item.title !== undefined) { - // Sanitize title for Unicode security - updateData.title = sanitizeTitle(item.title); - } - // Check if body updates are allowed (defaults to true if not specified) - const canUpdateBody = config.allow_body !== false; - if (item.body !== undefined && canUpdateBody) { - // Store operation information for consistent footer/append behavior. - // Default to "append" so we preserve the original issue text. - updateData._operation = item.operation || "append"; - updateData._rawBody = item.body; - } else if (item.body !== undefined && !canUpdateBody) { - // Body update attempted but not allowed by configuration - core.warning("Body update not allowed by safe-outputs configuration"); - } // The safe-outputs schema uses "status" (open/closed), while the GitHub API uses "state". // Accept both for compatibility. if (item.state !== undefined) { @@ -178,9 +169,6 @@ function buildIssueUpdateData(item, config) { return { success: false, error: assigneesLimitResult.error }; } - // Pass footer config to executeUpdate (default to true) - updateData._includeFooter = parseBoolTemplatable(config.footer, true); - // Store title prefix for validation in executeIssueUpdate if (config.title_prefix) { updateData._titlePrefix = config.title_prefix; diff --git a/setup/js/update_network_allowed.cjs b/setup/js/update_network_allowed.cjs new file mode 100644 index 0000000..a54a256 --- /dev/null +++ b/setup/js/update_network_allowed.cjs @@ -0,0 +1,139 @@ +// @ts-check +"use strict"; + +/** + * update_network_allowed.cjs + * + * Updates the AWF config file's network.allowDomains list based on the + * GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED environment variable. + * + * The variable contains a comma-separated list of ecosystem tokens (e.g. "node,python") + * or raw domain names. Each token is expanded to its known set of domains using the + * ecosystem map embedded via the GH_AW_ECOSYSTEM_MAP_JSON environment variable. + * Unknown tokens are treated as raw domain names. + * + * Environment variables: + * RUNNER_TEMP - GitHub Actions runner temp directory + * GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED - Comma-separated allowed tokens/domains + * GH_AW_ECOSYSTEM_MAP_JSON - JSON object mapping ecosystem names to domain arrays + * + * Exit codes: + * 0 — Success (including when no tokens are specified) + * 1 — Fatal error (missing RUNNER_TEMP, unreadable/invalid config file, write failure) + */ + +const fs = require("fs"); +const path = require("path"); + +const NETWORK_ALLOWED_ENV_VAR = "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED"; +/** @typedef {{allowDomains?: string[]}} AWFNetworkConfig */ +/** @typedef {Record & {network?: AWFNetworkConfig | unknown}} AWFConfig */ + +/** + * @param {any} value + * @returns {AWFNetworkConfig} + */ +function toNetworkConfig(value) { + return value; +} + +/** + * @param {any} value + * @returns {string[]} + */ +function toStringArray(value) { + return value; +} + +/** + * @returns {Promise} + */ +async function main() { + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + process.stderr.write("RUNNER_TEMP is not set\n"); + process.exit(1); + } + + const configPath = path.join(runnerTemp, "gh-aw", "awf-config.json"); + + /** @type {AWFConfig} */ + let config; + try { + config = JSON.parse(fs.readFileSync(configPath, "utf8")); + } catch (/** @type {unknown} */ err) { + const errCode = err && typeof err === "object" && "code" in err ? err.code : undefined; + const errMessage = err instanceof Error ? err.message : String(err); + if (errCode === "ENOENT") { + process.stderr.write(`Missing AWF config file at ${configPath}\n`); + } else if (err instanceof SyntaxError) { + process.stderr.write(`Invalid AWF config JSON at ${configPath}: ${errMessage}\n`); + } else { + process.stderr.write(`Failed to read AWF config file at ${configPath}: ${errMessage}\n`); + } + process.exit(1); + } + + const networkAllowed = process.env[NETWORK_ALLOWED_ENV_VAR] || ""; + const tokens = networkAllowed + .split(",") + .map(t => t.trim()) + .filter(t => t.length > 0); + + if (tokens.length > 0) { + const ecosystemMapJSON = process.env.GH_AW_ECOSYSTEM_MAP_JSON; + if (!ecosystemMapJSON) { + process.stderr.write("GH_AW_ECOSYSTEM_MAP_JSON is not set\n"); + process.exit(1); + } + + /** @type {Record} */ + let ecosystemMap; + try { + ecosystemMap = JSON.parse(ecosystemMapJSON); + } catch (/** @type {unknown} */ err) { + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Invalid GH_AW_ECOSYSTEM_MAP_JSON: ${errMessage}\n`); + process.exit(1); + } + + // Arrays are treated as malformed for this field and reset to an object shape. + if (!config.network || typeof config.network !== "object" || Array.isArray(config.network)) { + config.network = {}; + } + const network = toNetworkConfig(config.network); + if (!Array.isArray(network.allowDomains)) { + network.allowDomains = []; + } + const allowDomains = toStringArray(network.allowDomains); + const seen = new Set(allowDomains); + + for (const token of tokens) { + const domains = ecosystemMap[token] || [token]; + for (const domain of domains) { + if (!seen.has(domain)) { + allowDomains.push(domain); + seen.add(domain); + } + } + } + } + + try { + fs.writeFileSync(configPath, JSON.stringify(config) + "\n"); + } catch (/** @type {unknown} */ err) { + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Failed to write AWF config file at ${configPath}: ${errMessage}\n`); + process.exit(1); + } +} + +module.exports = { main }; + +if (require.main === module) { + main().catch((/** @type {unknown} */ err) => { + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${errMessage}\n`); + process.exit(1); + }); +} diff --git a/setup/js/update_pr_description_helpers.cjs b/setup/js/update_pr_description_helpers.cjs index f5bed7e..09791c6 100644 --- a/setup/js/update_pr_description_helpers.cjs +++ b/setup/js/update_pr_description_helpers.cjs @@ -103,12 +103,12 @@ function updateBody(params) { const sanitizedNewContent = sanitizeContent(newContent); // Inject CAUTION at top of new content if threat detection warning was raised - const detectionCaution = assembleMarkdownBodyParts({ + const { detectionCaution } = assembleMarkdownBodyParts({ includeFooter: false, workflowName, runUrl, - }).detectionCaution; - const contentWithCaution = detectionCaution ? detectionCaution + "\n\n" + sanitizedNewContent : sanitizedNewContent; + }); + const contentWithCaution = detectionCaution ? `${detectionCaution}\n\n${sanitizedNewContent}` : sanitizedNewContent; if (operation === "replace") { // Replace: use new content with optional AI footer diff --git a/setup/js/update_pull_request.cjs b/setup/js/update_pull_request.cjs index 7917ef8..f234725 100644 --- a/setup/js/update_pull_request.cjs +++ b/setup/js/update_pull_request.cjs @@ -11,8 +11,7 @@ const HANDLER_TYPE = "update_pull_request"; const { updateBody } = require("./update_pr_description_helpers.cjs"); const { resolveTarget, checkRequiredFilter } = require("./safe_output_helpers.cjs"); const { createUpdateHandlerFactory, createStandardResolveNumber, createStandardFormatResult } = require("./update_handler_factory.cjs"); -const { sanitizeTitle } = require("./sanitize_title.cjs"); -const { parseBoolTemplatable } = require("./templatable.cjs"); +const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -187,26 +186,13 @@ const resolvePRNumber = createStandardResolveNumber({ */ function buildPRUpdateData(item, config) { const canUpdateTitle = config.allow_title !== false; // Default true - const canUpdateBody = config.allow_body !== false; // Default true - - const updateData = {}; - let hasUpdates = false; - - if (canUpdateTitle && item.title !== undefined) { - // Sanitize title for Unicode security (no prefix handling needed for updates) - updateData.title = sanitizeTitle(item.title); - hasUpdates = true; - } - - if (canUpdateBody && item.body !== undefined) { - // Store operation information - // Use operation from item, or fall back to config default, or use "replace" as final default - const operation = item.operation || config.default_operation || "replace"; - updateData._operation = operation; - updateData._rawBody = item.body; - updateData.body = item.body; - hasUpdates = true; - } + const { updateData, hasCommonUpdates } = buildCommonEntityUpdateData(item, config, { + allowTitle: canUpdateTitle, + defaultOperation: "replace", + configDefaultOperation: config.default_operation, + includeBodyInApiData: true, + }); + let hasUpdates = hasCommonUpdates; // Other fields (always allowed) if (item.state !== undefined) { @@ -236,9 +222,6 @@ function buildPRUpdateData(item, config) { }; } - // Pass footer config to executeUpdate (default to true) - updateData._includeFooter = parseBoolTemplatable(config.footer, true); - return { success: true, data: updateData }; } diff --git a/setup/js/write_daily_aic_usage_cache.cjs b/setup/js/write_daily_aic_usage_cache.cjs index bb9f074..fb7928d 100644 --- a/setup/js/write_daily_aic_usage_cache.cjs +++ b/setup/js/write_daily_aic_usage_cache.cjs @@ -35,17 +35,15 @@ const USAGE_DIR = "/tmp/gh-aw/usage"; * @param {Record} [details] */ function logCache(message, details) { - const suffix = - details && Object.keys(details).length > 0 - ? ": " + - (() => { - try { - return JSON.stringify(details); - } catch { - return "{}"; - } - })() - : ""; + let suffix = ""; + if (details && Object.keys(details).length > 0) { + try { + suffix = ": " + JSON.stringify(details); + } catch (e) { + core.warning(`[daily-aic-cache] logCache: could not serialise details: ${e}`); + suffix = ": {}"; + } + } core.info(`[daily-aic-cache] ${message}${suffix}`); } diff --git a/setup/md/engine_max_runs_exceeded.md b/setup/md/engine_max_runs_exceeded.md new file mode 100644 index 0000000..daaf04c --- /dev/null +++ b/setup/md/engine_max_runs_exceeded.md @@ -0,0 +1,9 @@ +> [!WARNING] +> **Engine Max Runs Exceeded**: The {engine_label} engine hit the workflow max runs guardrail and could not continue. + +This signal was detected from engine runtime logs. + +**What to do next** +- Increase workflow `max-runs` if the task legitimately needs more model invocations. +- Reduce per-run model calls by simplifying prompts, limiting retries, or breaking work into smaller steps. +- Review the run logs to identify repeated loops or retries that consumed invocation budget unexpectedly. diff --git a/setup/setup.sh b/setup/setup.sh index 514d15b..8f21356 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -331,6 +331,7 @@ SAFE_OUTPUTS_FILES=( "missing_info_formatter.cjs" "sanitize_content.cjs" "sanitize_content_core.cjs" + "slash_command_matcher.cjs" "sanitize_title.cjs" "issue_title_dedup.cjs" "levenshtein_distance.cjs"