diff --git a/setup/js/ai_credits_context.cjs b/setup/js/ai_credits_context.cjs index 14f9fea..5425305 100644 --- a/setup/js/ai_credits_context.cjs +++ b/setup/js/ai_credits_context.cjs @@ -319,6 +319,8 @@ function resolveAICreditsFailureState({ logProvenance = true } = {}) { const { aiCredits: auditAICredits, maxAICredits: auditMaxAICredits, rateLimitError: auditRateLimitError, maxAICreditsExceeded: auditMaxAICreditsExceeded } = parseAuditLogCombined(); const envAICredits = parsePositiveNumberString(process.env.GH_AW_AIC); const envMaxAICredits = parsePositiveNumberString(process.env.GH_AW_MAX_AI_CREDITS); + const envRateLimitSignal = process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true"; + const envRateLimitSignalHasEvidence = envRateLimitSignal && !!(auditAICredits || stdioSignals.aiCredits || envAICredits); // Log provenance so failing issues can be diagnosed when credit data is missing. if (logProvenance) { @@ -342,13 +344,21 @@ function resolveAICreditsFailureState({ logProvenance = true } = {}) { console.log(`[ai-credits] maxAICredits source=none GH_AW_MAX_AI_CREDITS=${process.env.GH_AW_MAX_AI_CREDITS || "(unset)"}`); } - const rawRateLimitSignalSource = auditRateLimitError ? "audit_log" : stdioSignals.rateLimitError ? "agent_stdio" : process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true" ? "env(GH_AW_AI_CREDITS_RATE_LIMIT_ERROR)" : "none"; + const rawRateLimitSignalSource = auditRateLimitError + ? "audit_log" + : stdioSignals.rateLimitError + ? "agent_stdio" + : envRateLimitSignalHasEvidence + ? "env(GH_AW_AI_CREDITS_RATE_LIMIT_ERROR)" + : envRateLimitSignal + ? "env_ignored_no_ai_credits" + : "none"; console.log(`[ai-credits] rateLimitSignal source=${rawRateLimitSignalSource}`); } const aiCredits = auditAICredits || stdioSignals.aiCredits || envAICredits || ""; const maxAICredits = auditMaxAICredits || stdioSignals.maxAICredits || envMaxAICredits || ""; - const rawAICreditsRateLimitError = auditRateLimitError || stdioSignals.rateLimitError || process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true"; + const rawAICreditsRateLimitError = auditRateLimitError || stdioSignals.rateLimitError || envRateLimitSignalHasEvidence; const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError, aiCredits, maxAICredits); return { aiCredits, maxAICredits, aiCreditsRateLimitError, maxAICreditsExceeded: auditMaxAICreditsExceeded || stdioSignals.maxAICreditsExceeded }; } diff --git a/setup/js/apply_samples.cjs b/setup/js/apply_samples.cjs index 6827414..c632661 100644 --- a/setup/js/apply_samples.cjs +++ b/setup/js/apply_samples.cjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // @ts-check /// +// @safe-outputs-exempt SEC-005: target repo is used only for read-only PR head-ref lookups during deterministic sample replay; never derived from agent safe-output content and never used for a cross-repo write. // apply_samples.cjs // diff --git a/setup/js/artifact_client.cjs b/setup/js/artifact_client.cjs index 1b46333..40b6974 100644 --- a/setup/js/artifact_client.cjs +++ b/setup/js/artifact_client.cjs @@ -1,6 +1,12 @@ // @ts-check /// +/** + * @safe-outputs-exempt SEC-004: "body" references are HTTP transport payloads + * (Twirp RPC request JSON bodies and artifact upload stream bodies), not + * user-authored issue/PR/comment bodies. + */ + const crypto = require("crypto"); const fs = require("fs"); const os = require("os"); diff --git a/setup/js/check_daily_aic_workflow_guardrail.cjs b/setup/js/check_daily_aic_workflow_guardrail.cjs index 6896ffa..905f42c 100644 --- a/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -382,6 +382,11 @@ async function main() { const candidateRuns = []; let page = 1; let truncatedByRateLimit = false; + // listWorkflowRuns returns runs in descending creation order (newest first). + // The first run whose created_at falls before the cutoff means all remaining + // runs on this page and every subsequent page are also outside the window, so + // we can stop paginating immediately rather than exhausting the page budget. + let reachedCutoff = false; while (page <= MAX_WORKFLOW_RUN_PAGES) { logDailyGuardrail("Querying completed workflow runs", { workflowId: currentRun.data.workflow_id, @@ -401,7 +406,8 @@ async function main() { logDailyGuardrail("Received workflow runs page", { page, runCount: runs.length, - runIds: runs.map(run => run?.id).filter(Boolean), + firstRunId: runs[0]?.id ?? null, + lastRunId: runs[runs.length - 1]?.id ?? null, }); if (runs.length === 0) { break; @@ -412,7 +418,10 @@ async function main() { } const createdAtMs = Date.parse(run.created_at || ""); if (!Number.isFinite(createdAtMs) || createdAtMs < cutoffMs) { - continue; + // Runs are newest-first; any run older than the cutoff means all + // remaining runs (and pages) are also outside the 24h window. + reachedCutoff = true; + break; } candidateRuns.push(run); if (candidateRuns.length >= maxInspectableRuns) { @@ -420,7 +429,7 @@ async function main() { break; } } - if (candidateRuns.length >= maxInspectableRuns || runs.length < 100) { + if (reachedCutoff || candidateRuns.length >= maxInspectableRuns || runs.length < 100) { break; } page += 1; @@ -437,10 +446,6 @@ async function main() { /** @type {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number}>} */ const countedRuns = []; for (const run of candidateRuns) { - if (countedRuns.length >= maxInspectableRuns) { - truncatedByRateLimit = true; - break; - } try { const runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo); if (runAIC <= 0) { diff --git a/setup/js/convert_gateway_config_copilot.cjs b/setup/js/convert_gateway_config_copilot.cjs index c574b73..bda12b2 100644 --- a/setup/js/convert_gateway_config_copilot.cjs +++ b/setup/js/convert_gateway_config_copilot.cjs @@ -10,20 +10,43 @@ require("./shim.cjs"); * Converts the MCP gateway's standard HTTP-based configuration to the format * expected by GitHub Copilot CLI. Reads the gateway output JSON, filters out * CLI-mounted servers, adds tools:["*"] if missing, rewrites URLs to use the - * correct domain, and writes the result to /home/runner/.copilot/mcp-config.json. + * correct domain, and writes the result to $HOME/.copilot/mcp-config.json + * (typically /home/runner/.copilot/mcp-config.json on GitHub-hosted runners, + * but may differ on self-hosted or containerized runners where HOME varies). * * Required environment variables: * - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file * - MCP_GATEWAY_DOMAIN: Domain for MCP server URLs (e.g., host.docker.internal) * - MCP_GATEWAY_PORT: Port for MCP gateway (e.g., 80) + * - HOME: User home directory (standard POSIX env var inherited by the runner) * * Optional: * - GH_AW_MCP_CLI_SERVERS: JSON array of server names to exclude from agent config */ +const path = require("path"); const { rewriteUrl, loadGatewayContext, logCLIFilters, filterAndTransformServers, logServerStats, writeSecureOutput } = require("./convert_gateway_config_shared.cjs"); -const OUTPUT_PATH = "/home/runner/.copilot/mcp-config.json"; +/** + * Resolves the Copilot CLI MCP config output path from the runtime $HOME. + * The Copilot CLI uses ~/.copilot, which is /home/runner/.copilot on standard + * GitHub-hosted runners (HOME=/home/runner) but may differ on self-hosted or + * containerized runners. HOME is a standard POSIX environment variable inherited + * from the runner's parent process and passed through to shell steps; other + * generators (copilot_mcp.go, copilot_engine_execution.go) rely on it the same way. + * + * Exported for testability; throws Error rather than exiting so tests can + * exercise the missing-HOME branch. + * + * @returns {string} + */ +function resolveCopilotConfigOutputPath() { + const home = process.env.HOME; + if (!home) { + throw new Error("HOME environment variable is not set; cannot locate Copilot CLI config directory"); + } + return path.join(home, ".copilot", "mcp-config.json"); +} /** * @param {Record} entry @@ -44,6 +67,14 @@ function transformCopilotEntry(entry, urlPrefix) { } function main() { + let outputPath; + try { + outputPath = resolveCopilotConfigOutputPath(); + } catch (err) { + core.error(`ERROR: ${err.message}`); + process.exit(1); + } + const { gatewayOutput, domain, port, urlPrefix, cliServers, servers } = loadGatewayContext(); core.info("Converting gateway configuration to Copilot format..."); @@ -58,9 +89,9 @@ function main() { // Write with owner-only permissions (0o600) to protect the gateway bearer token. // An attacker who reads mcp-config.json could bypass --allowed-tools by issuing // raw JSON-RPC calls directly to the gateway. - writeSecureOutput(OUTPUT_PATH, output); + writeSecureOutput(outputPath, output); - core.info(`Copilot configuration written to ${OUTPUT_PATH}`); + core.info(`Copilot configuration written to ${outputPath}`); core.info(""); core.info("Converted configuration:"); core.info(output); @@ -70,4 +101,4 @@ if (require.main === module) { main(); } -module.exports = { rewriteUrl, transformCopilotEntry, main }; +module.exports = { rewriteUrl, transformCopilotEntry, resolveCopilotConfigOutputPath, main }; diff --git a/setup/js/emit_outcome_spans.cjs b/setup/js/emit_outcome_spans.cjs index 4b2ed4a..9572a00 100644 --- a/setup/js/emit_outcome_spans.cjs +++ b/setup/js/emit_outcome_spans.cjs @@ -86,12 +86,12 @@ async function main() { console.log("[outcome-otel] No OTLP endpoints configured, writing JSONL mirror only"); } - // Read aw_info.json first: GH_AW_INFO_VERSION and GH_AW_INFO_STAGED are only - // present during setup, while aw_info.json is the authoritative runtime - // source for later github-script steps. Prefer agent_version when available - // to match the other OTEL helpers' service/scope version attribution. + // Read aw_info.json first: GH_AW_INFO_* env vars are only present during setup, + // while aw_info.json is the authoritative runtime source for later + // github-script steps. Prefer cli_version for CLI-named version dimensions, + // and only fall back to engine version fields when CLI version is unavailable. const staged = awInfo.staged === true || process.env.GH_AW_INFO_STAGED === "true"; - const scopeVersion = awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || "unknown"; + const scopeVersion = (typeof awInfo.cli_version === "string" ? awInfo.cli_version : "") || process.env.GH_AW_INFO_CLI_VERSION || awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || "unknown"; const traceId = (process.env.GITHUB_AW_OTEL_TRACE_ID || "").trim().toLowerCase() || generateTraceId(); const parentSpanId = (process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID || "").trim().toLowerCase() || ""; const summarySpanId = generateSpanId(); diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index e9eb3dd..6728a50 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -11,7 +11,7 @@ const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs"); const { formatMissingData, formatMissingTools } = require("./missing_info_formatter.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { AWF_INFRA_LINE_RE } = require("./log_parser_shared.cjs"); -const { resolveFirewallAuditLogPath, resolveAICreditsFailureState, parseMaxAICreditsFromAuditLog, parseAICreditsErrorInfoFromAuditLog } = require("./ai_credits_context.cjs"); +const { resolveFirewallAuditLogPath, resolveAICreditsFailureState, parseMaxAICreditsFromAuditLog, parseAICreditsErrorInfoFromAuditLog, parseUnknownModelAICreditsFromAuditLog } = require("./ai_credits_context.cjs"); const { formatAICCredits } = require("./daily_aic_workflow_helpers.cjs"); const { formatAIC } = require("./model_costs.cjs"); const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp_gateway_log.cjs"); @@ -2382,7 +2382,9 @@ async function main() { const mcpPolicyError = process.env.GH_AW_MCP_POLICY_ERROR === "true"; const agenticEngineTimeout = process.env.GH_AW_AGENTIC_ENGINE_TIMEOUT === "true"; const modelNotSupportedError = process.env.GH_AW_MODEL_NOT_SUPPORTED_ERROR === "true"; - const unknownModelAICredits = process.env.GH_AW_UNKNOWN_MODEL_AI_CREDITS === "true"; + const unknownModelAICreditsFromOutput = process.env.GH_AW_UNKNOWN_MODEL_AI_CREDITS === "true"; + const unknownModelAICreditsFromAudit = parseUnknownModelAICreditsFromAuditLog(); + const unknownModelAICredits = unknownModelAICreditsFromAudit || (unknownModelAICreditsFromOutput && agentConclusion === "failure"); const pushRepoMemoryResult = process.env.GH_AW_PUSH_REPO_MEMORY_RESULT || ""; const reportFailureAsIssue = process.env.GH_AW_FAILURE_REPORT_AS_ISSUE !== "false"; // Default to true // Feature flags: control whether missing_tool/missing_data signals trigger agent failure handling. @@ -2450,6 +2452,7 @@ async function main() { core.info(`Agentic engine timeout: ${agenticEngineTimeout}`); core.info(`Model not supported error: ${modelNotSupportedError}`); core.info(`Unknown model AI credits error: ${unknownModelAICredits}`); + core.info(`Unknown model AI credits sources (audit/output): ${unknownModelAICreditsFromAudit}/${unknownModelAICreditsFromOutput}`); core.info(`Push repo-memory result: ${pushRepoMemoryResult}`); core.info(`App token minting failed (safe_outputs/conclusion/activation): ${safeOutputsAppTokenMintingFailed}/${conclusionAppTokenMintingFailed}/${activationAppTokenMintingFailed}`); core.info(`Lockdown check failed: ${hasLockdownCheckFailed}`); diff --git a/setup/js/log_parser_bootstrap.cjs b/setup/js/log_parser_bootstrap.cjs index 99a06b9..8482b9d 100644 --- a/setup/js/log_parser_bootstrap.cjs +++ b/setup/js/log_parser_bootstrap.cjs @@ -164,8 +164,8 @@ async function runLogParser(options) { // Generate lightweight plain text summary for core.info and Copilot CLI style for step summary if (logEntries && Array.isArray(logEntries) && logEntries.length > 0) { // Extract model from init entry if available - const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); - const model = initEntry?.model || null; + const initEntry = logEntries.find(entry => (entry.type === "system" && entry.subtype === "init") || entry.type === "session.init"); + const model = initEntry?.model || initEntry?.data?.model || null; const plainTextSummary = generatePlainTextSummary(logEntries, { model, diff --git a/setup/js/log_parser_format.cjs b/setup/js/log_parser_format.cjs index 4c9a5cd..43fa01d 100644 --- a/setup/js/log_parser_format.cjs +++ b/setup/js/log_parser_format.cjs @@ -15,6 +15,8 @@ * @property {(text: string) => number} estimateTokens * @property {(ms: number) => string} formatDuration * @property {(text: string) => string} unfenceMarkdown + * @property {(entries: Array) => boolean} isCopilotEventLogEntries + * @property {(entries: Array) => Array} convertCopilotEventsToLegacyLogEntries * @property {number} MAX_AGENT_TEXT_LENGTH * @property {string} SIZE_LIMIT_WARNING */ @@ -48,12 +50,21 @@ function createLogParserFormatters(deps) { estimateTokens, formatDuration, unfenceMarkdown, + isCopilotEventLogEntries, + convertCopilotEventsToLegacyLogEntries, MAX_AGENT_TEXT_LENGTH, SIZE_LIMIT_WARNING, } = deps; const INTERNAL_TOOLS = ["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"]; + function normalizeEntriesForRendering(logEntries) { + if (isCopilotEventLogEntries(logEntries)) { + return convertCopilotEventsToLegacyLogEntries(logEntries); + } + return logEntries; + } + /** * Generates markdown summary from conversation log entries * This is the core shared logic between Claude and Copilot log parsers @@ -70,8 +81,9 @@ function createLogParserFormatters(deps) { */ function generateConversationMarkdown(logEntries, options) { const { formatToolCallback, formatInitCallback, summaryTracker } = options; + const renderEntries = normalizeEntriesForRendering(logEntries); - const toolUsePairs = collectToolUsePairs(logEntries); + const toolUsePairs = collectToolUsePairs(renderEntries); let markdown = ""; let sizeLimitReached = false; @@ -85,7 +97,7 @@ function createLogParserFormatters(deps) { return true; } - const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); + const initEntry = renderEntries.find(entry => entry.type === "system" && entry.subtype === "init"); if (initEntry && formatInitCallback) { if (!addContent("## 🚀 Initialization\n\n")) { @@ -110,7 +122,7 @@ function createLogParserFormatters(deps) { return { markdown, commandSummary: [], sizeLimitReached }; } - for (const entry of logEntries) { + for (const entry of renderEntries) { if (sizeLimitReached) break; if (entry.type === "assistant" && entry.message?.content) { @@ -158,7 +170,7 @@ function createLogParserFormatters(deps) { const commandSummary = []; - for (const entry of logEntries) { + for (const entry of renderEntries) { if (entry.type === "assistant" && entry.message?.content) { for (const content of entry.message.content) { if (content.type === "tool_use") { @@ -522,8 +534,9 @@ function createLogParserFormatters(deps) { } function generateSummaryLines(logEntries) { + const renderEntries = normalizeEntriesForRendering(logEntries); const lines = []; - const toolUsePairs = collectToolUsePairs(logEntries); + const toolUsePairs = collectToolUsePairs(renderEntries); const state = { conversationLineCount: 0, @@ -532,7 +545,7 @@ function createLogParserFormatters(deps) { traceEventCount: 0, }; - for (const entry of logEntries) { + for (const entry of renderEntries) { if (state.conversationLineCount >= state.maxConversationLines) { state.conversationTruncated = true; break; @@ -569,7 +582,7 @@ function createLogParserFormatters(deps) { lines.push(""); } - appendStatistics(lines, logEntries, toolUsePairs); + appendStatistics(lines, renderEntries, toolUsePairs); return lines; } diff --git a/setup/js/log_parser_shared.cjs b/setup/js/log_parser_shared.cjs index 6fa5f8e..e0a5a06 100644 --- a/setup/js/log_parser_shared.cjs +++ b/setup/js/log_parser_shared.cjs @@ -610,6 +610,388 @@ function parseLogEntries(logContent) { return logEntries; } +/** + * Detects whether entries are in Copilot event log format. + * @param {Array} logEntries + * @returns {boolean} + */ +function isCopilotEventLogEntries(logEntries) { + if (!Array.isArray(logEntries) || logEntries.length === 0) { + return false; + } + + const eventTypePrefixes = ["user.", "assistant.", "tool.", "session."]; + let eventLikeCount = 0; + + for (const entry of logEntries) { + if (!entry || typeof entry !== "object" || typeof entry.type !== "string") continue; + if (entry.type === "assistant" || entry.type === "user" || entry.type === "system" || entry.type === "result") { + return false; + } + if (eventTypePrefixes.some(prefix => entry.type.startsWith(prefix))) { + eventLikeCount++; + } + } + + return eventLikeCount > 0; +} + +/** + * Converts legacy trace entries to Copilot event log format. + * @param {Array} logEntries + * @param {{sourceEngine?: string}} [options] + * @returns {Array} + */ +function convertLegacyLogEntriesToCopilotEvents(logEntries, options = {}) { + if (!Array.isArray(logEntries) || logEntries.length === 0) { + return []; + } + if (isCopilotEventLogEntries(logEntries)) { + return logEntries; + } + + const { sourceEngine = "unknown" } = options; + /** @type {Array} */ + const events = []; + const toolUsesById = new Map(); + + for (const entry of logEntries) { + if (!entry || typeof entry !== "object") continue; + + if (entry.type === "system" && entry.subtype === "init") { + events.push({ + type: "session.init", + data: { + sourceEngine, + model: entry.model, + sessionId: entry.session_id, + cwd: entry.cwd, + tools: Array.isArray(entry.tools) ? entry.tools : [], + mcpServers: Array.isArray(entry.mcp_servers) ? entry.mcp_servers : [], + slashCommands: Array.isArray(entry.slash_commands) ? entry.slash_commands : [], + modelInfo: entry.model_info, + }, + }); + continue; + } + + if (entry.type === "system" && entry.subtype && entry.subtype !== "init") { + if (entry.message?.content && Array.isArray(entry.message.content)) { + for (const content of entry.message.content) { + if (content?.type === "text" && typeof content.text === "string" && content.text.trim()) { + events.push({ + type: "assistant.message", + data: { content: content.text }, + }); + } + } + } else if (typeof entry.message === "string" && entry.message.trim()) { + events.push({ + type: "assistant.message", + data: { content: entry.message }, + }); + } + continue; + } + + if (entry.type === "assistant" && entry.message?.content && Array.isArray(entry.message.content)) { + for (const content of entry.message.content) { + if (!content || typeof content !== "object") continue; + + if (content.type === "text" && typeof content.text === "string" && content.text.trim()) { + events.push({ + type: "assistant.message", + data: { content: content.text }, + }); + } else if (content.type === "thinking" && typeof content.thinking === "string" && content.thinking.trim()) { + events.push({ + type: "assistant.reasoning", + data: { content: content.thinking }, + }); + } else if (content.type === "tool_use") { + const toolCallId = typeof content.id === "string" && content.id.trim() ? content.id : `tool_${events.length + 1}`; + toolUsesById.set(toolCallId, content); + events.push({ + type: "tool.execution_start", + data: { + toolCallId, + toolName: content.name, + input: content.input || {}, + }, + }); + } + } + continue; + } + + if (entry.type === "user" && entry.message?.content && Array.isArray(entry.message.content)) { + for (const content of entry.message.content) { + if (!content || content.type !== "tool_result") continue; + const toolCallId = typeof content.tool_use_id === "string" && content.tool_use_id.trim() ? content.tool_use_id : `tool_${events.length + 1}`; + const toolUse = toolUsesById.get(toolCallId); + events.push({ + type: "tool.execution_complete", + data: { + toolCallId, + toolName: toolUse?.name, + success: content.is_error !== true, + output: content.content, + durationMs: content.duration_ms, + }, + }); + } + continue; + } + + if (entry.type === "result") { + events.push({ + type: "session.result", + data: { + numTurns: entry.num_turns, + durationMs: entry.duration_ms, + totalCostUsd: entry.total_cost_usd, + usage: entry.usage, + errors: entry.errors, + permissionDenials: entry.permission_denials, + }, + }); + } + } + + return events; +} + +/** + * Converts Copilot event log entries to legacy trace entries used by renderers. + * @param {Array} logEntries + * @returns {Array} + */ +function convertCopilotEventsToLegacyLogEntries(logEntries) { + if (!Array.isArray(logEntries) || logEntries.length === 0) { + return []; + } + if (!isCopilotEventLogEntries(logEntries)) { + return logEntries; + } + + /** @type {Array} */ + const normalizedEntries = []; + const pendingByToolCallId = new Map(); + const pendingIdsByToolName = new Map(); + let toolCounter = 0; + let turnCount = 0; + let assistantMessageCount = 0; + + const addPendingId = (toolName, toolId) => { + const existing = pendingIdsByToolName.get(toolName); + if (existing) { + existing.push(toolId); + return; + } + pendingIdsByToolName.set(toolName, [toolId]); + }; + + const shiftPendingId = toolName => { + const existing = pendingIdsByToolName.get(toolName); + if (!existing || existing.length === 0) return null; + const toolId = existing.shift(); + if (existing.length === 0) { + pendingIdsByToolName.delete(toolName); + } + return toolId || null; + }; + + const removePendingId = (toolName, toolId) => { + const existing = pendingIdsByToolName.get(toolName); + if (!existing || existing.length === 0) return; + const idx = existing.indexOf(toolId); + if (idx === -1) return; + existing.splice(idx, 1); + if (existing.length === 0) { + pendingIdsByToolName.delete(toolName); + } + }; + + const normalizeToolName = (rawToolName, mcpServerName) => { + const toolName = typeof rawToolName === "string" && rawToolName.trim() ? rawToolName.trim() : "unknown"; + if (toolName.startsWith("mcp__")) { + return toolName; + } + const serverName = typeof mcpServerName === "string" ? mcpServerName.trim() : ""; + if (!serverName) { + return toolName; + } + return `mcp__${serverName}__${toolName}`; + }; + + const readString = (...values) => { + for (const value of values) { + if (typeof value === "string") return value; + } + return ""; + }; + + for (const entry of logEntries) { + if (!entry || typeof entry !== "object") continue; + const data = entry.data && typeof entry.data === "object" ? entry.data : {}; + + switch (entry.type) { + case "session.init": + normalizedEntries.push({ + type: "system", + subtype: "init", + model: data.model, + session_id: data.sessionId, + cwd: data.cwd, + tools: Array.isArray(data.tools) ? data.tools : [], + mcp_servers: Array.isArray(data.mcpServers) ? data.mcpServers : [], + slash_commands: Array.isArray(data.slashCommands) ? data.slashCommands : [], + model_info: data.modelInfo, + }); + break; + + case "user.message": + turnCount++; + break; + + case "assistant.message": { + const text = readString(data.content, data.message); + if (!text.trim()) break; + assistantMessageCount++; + normalizedEntries.push({ + type: "assistant", + message: { + content: [{ type: "text", text }], + }, + }); + break; + } + + case "assistant.reasoning": + case "reasoning": { + const text = typeof data.content === "string" ? data.content : ""; + if (!text.trim()) break; + normalizedEntries.push({ + type: "assistant", + message: { + content: [{ type: "thinking", thinking: text }], + }, + }); + break; + } + + case "tool.execution_start": { + const toolName = normalizeToolName(data.toolName, data.mcpServerName); + const toolCallId = typeof data.toolCallId === "string" && data.toolCallId.trim() ? data.toolCallId : null; + const resolvedToolId = toolCallId || `sdk_tool_${++toolCounter}`; + if (toolCallId) { + pendingByToolCallId.set(toolCallId, resolvedToolId); + } + addPendingId(toolName, resolvedToolId); + normalizedEntries.push({ + type: "assistant", + message: { + content: [{ type: "tool_use", id: resolvedToolId, name: toolName, input: data.input || data.parameters || {} }], + }, + }); + break; + } + + case "tool.execution_complete": { + const toolName = normalizeToolName(data.toolName, data.mcpServerName); + const toolCallId = typeof data.toolCallId === "string" && data.toolCallId.trim() ? data.toolCallId : null; + let resolvedToolId = null; + + if (toolCallId && pendingByToolCallId.has(toolCallId)) { + resolvedToolId = pendingByToolCallId.get(toolCallId); + pendingByToolCallId.delete(toolCallId); + if (resolvedToolId) { + removePendingId(toolName, resolvedToolId); + } + } + if (!resolvedToolId) { + resolvedToolId = shiftPendingId(toolName); + } + if (!resolvedToolId) { + resolvedToolId = `sdk_tool_${++toolCounter}`; + normalizedEntries.push({ + type: "assistant", + message: { + content: [{ type: "tool_use", id: resolvedToolId, name: toolName, input: data.input || data.parameters || {} }], + }, + }); + } + + const success = typeof data.success === "boolean" ? data.success : !data.error; + let output = ""; + if (typeof data.output === "string") { + output = data.output; + } else if (typeof data.result === "string") { + output = data.result; + } else if (data.error) { + output = String(data.error); + } else if (success) { + output = "success"; + } else { + output = "Tool execution failed"; + } + + normalizedEntries.push({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: resolvedToolId, + content: output, + is_error: !success, + duration_ms: typeof data.durationMs === "number" ? data.durationMs : undefined, + }, + ], + }, + }); + break; + } + + case "session.result": { + const usage = data.usage && typeof data.usage === "object" ? data.usage : {}; + normalizedEntries.push({ + type: "result", + num_turns: typeof data.numTurns === "number" ? data.numTurns : undefined, + duration_ms: typeof data.durationMs === "number" ? data.durationMs : undefined, + total_cost_usd: typeof data.totalCostUsd === "number" ? data.totalCostUsd : undefined, + usage: { + input_tokens: usage.input_tokens ?? usage.inputTokens, + output_tokens: usage.output_tokens ?? usage.outputTokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens, + cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.cacheReadInputTokens, + }, + errors: Array.isArray(data.errors) ? data.errors : undefined, + permission_denials: Array.isArray(data.permissionDenials) ? data.permissionDenials : undefined, + }); + break; + } + + default: + break; + } + } + + if (normalizedEntries.length === 0) { + return []; + } + + const hasResult = normalizedEntries.some(entry => entry.type === "result"); + if (!hasResult) { + normalizedEntries.push({ + type: "result", + num_turns: turnCount > 0 ? turnCount : assistantMessageCount, + }); + } + + return normalizedEntries; +} + const { generateConversationMarkdown, formatToolUse, generatePlainTextSummary, generateCopilotCliStyleSummary } = createLogParserFormatters({ formatBashCommand, formatMcpName, @@ -621,6 +1003,8 @@ const { generateConversationMarkdown, formatToolUse, generatePlainTextSummary, g estimateTokens, formatDuration, unfenceMarkdown, + isCopilotEventLogEntries, + convertCopilotEventsToLegacyLogEntries, MAX_AGENT_TEXT_LENGTH, SIZE_LIMIT_WARNING, }); @@ -963,6 +1347,9 @@ module.exports = { formatToolDisplayName, formatInitializationSummary, formatToolUse, + isCopilotEventLogEntries, + convertLegacyLogEntriesToCopilotEvents, + convertCopilotEventsToLegacyLogEntries, parseLogEntries, formatToolCallAsDetails, formatResultPreview, diff --git a/setup/js/otlp.cjs b/setup/js/otlp.cjs index 1b415b1..05eadd8 100644 --- a/setup/js/otlp.cjs +++ b/setup/js/otlp.cjs @@ -105,7 +105,7 @@ async function logSpan(toolName, attributes = {}, options = {}) { // source (written by generate_aw_info.cjs and read by conclusion spans). const awInfo = readJSONIfExists("/tmp/gh-aw/aw_info.json") || {}; const staged = awInfo.staged === true || process.env.GH_AW_INFO_STAGED === "true"; - const scopeVersion = awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || "unknown"; + const scopeVersion = (typeof awInfo.cli_version === "string" ? awInfo.cli_version : "") || process.env.GH_AW_INFO_CLI_VERSION || awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || "unknown"; const awfVersion = (typeof awInfo.awf_version === "string" ? awInfo.awf_version : "") || process.env.GH_AW_INFO_AWF_VERSION || ""; const awmgVersion = (typeof awInfo.awmg_version === "string" ? awInfo.awmg_version : "") || process.env.GH_AW_INFO_AWMG_VERSION || ""; const serviceName = process.env.OTEL_SERVICE_NAME || "gh-aw"; diff --git a/setup/js/parse_antigravity_log.cjs b/setup/js/parse_antigravity_log.cjs index f5bb0c1..cf38e08 100644 --- a/setup/js/parse_antigravity_log.cjs +++ b/setup/js/parse_antigravity_log.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { createEngineLogParser, generateInformationSection } = require("./log_parser_shared.cjs"); +const { createEngineLogParser, generateInformationSection, convertLegacyLogEntriesToCopilotEvents } = require("./log_parser_shared.cjs"); const main = createEngineLogParser({ parserName: "Antigravity", @@ -112,9 +112,11 @@ function parseAntigravityLog(logContent) { }); } + const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "antigravity" }); + return { markdown, - logEntries, + logEntries: canonicalLogEntries, mcpFailures: [], maxTurnsHit: false, }; diff --git a/setup/js/parse_claude_log.cjs b/setup/js/parse_claude_log.cjs index 1e20f93..b681975 100644 --- a/setup/js/parse_claude_log.cjs +++ b/setup/js/parse_claude_log.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse, parseLogEntries } = require("./log_parser_shared.cjs"); +const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse, parseLogEntries, convertLegacyLogEntriesToCopilotEvents } = require("./log_parser_shared.cjs"); const main = createEngineLogParser({ parserName: "Claude", @@ -30,7 +30,8 @@ function parseClaudeLog(logContent) { const mcpFailures = []; // Generate conversation markdown using shared function - const conversationResult = generateConversationMarkdown(logEntries, { + const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "claude" }); + const conversationResult = generateConversationMarkdown(canonicalLogEntries, { formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: false }), formatInitCallback: initEntry => { const result = formatInitializationSummary(initEntry, { @@ -98,7 +99,7 @@ function parseClaudeLog(logContent) { } } - return { markdown, mcpFailures, maxTurnsHit, logEntries }; + return { markdown, mcpFailures, maxTurnsHit, logEntries: canonicalLogEntries }; } // Export for testing diff --git a/setup/js/parse_codex_log.cjs b/setup/js/parse_codex_log.cjs index 36a8821..e61c1e5 100644 --- a/setup/js/parse_codex_log.cjs +++ b/setup/js/parse_codex_log.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { createEngineLogParser, truncateString, estimateTokens, formatToolCallAsDetails } = require("./log_parser_shared.cjs"); +const { createEngineLogParser, truncateString, estimateTokens, formatToolCallAsDetails, convertLegacyLogEntriesToCopilotEvents } = require("./log_parser_shared.cjs"); const main = createEngineLogParser({ parserName: "Codex", @@ -643,9 +643,11 @@ function parseCodexLog(logContent) { // Check for MCP failures const mcpFailures = mcpInfo.servers.filter(server => server.status === "failed").map(server => server.name); + const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "codex" }); + return { markdown, - logEntries, + logEntries: canonicalLogEntries, mcpFailures, maxTurnsHit: false, // Codex doesn't have max-turns concept in logs }; diff --git a/setup/js/parse_copilot_log.cjs b/setup/js/parse_copilot_log.cjs index dc1388e..c94b994 100644 --- a/setup/js/parse_copilot_log.cjs +++ b/setup/js/parse_copilot_log.cjs @@ -1,7 +1,18 @@ // @ts-check /// -const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse, parseLogEntries, AWF_INFRA_LINE_RE } = require("./log_parser_shared.cjs"); +const { + createEngineLogParser, + generateConversationMarkdown, + generateInformationSection, + formatInitializationSummary, + formatToolUse, + parseLogEntries, + AWF_INFRA_LINE_RE, + isCopilotEventLogEntries, + convertLegacyLogEntriesToCopilotEvents, + convertCopilotEventsToLegacyLogEntries, +} = require("./log_parser_shared.cjs"); const { ERR_PARSE } = require("./error_codes.cjs"); const main = createEngineLogParser({ @@ -52,10 +63,12 @@ function extractAwfTokenWarnings(logEntries) { if (typeof value.message === "string") addMatches(value.message); if (typeof value.content === "string") addMatches(value.content); if (typeof value.system === "string") addMatches(value.system); + if (typeof value.data?.content === "string") addMatches(value.data.content); if (Array.isArray(value.content)) visit(value.content); if (Array.isArray(value.message?.content)) visit(value.message.content); if (Array.isArray(value.system)) visit(value.system); + if (value.data && typeof value.data === "object") visit(value.data); }; for (const entry of logEntries) visit(entry); @@ -69,208 +82,7 @@ function extractAwfTokenWarnings(logEntries) { * @returns {boolean} */ function isCopilotSdkEventsFormat(logEntries) { - if (!Array.isArray(logEntries) || logEntries.length === 0) { - return false; - } - - const sdkEventTypes = new Set(["user.message", "assistant.message", "tool.execution_start", "tool.execution_complete", "reasoning", "assistant.reasoning"]); - let sdkLikeCount = 0; - - for (const entry of logEntries) { - if (!entry || typeof entry !== "object") continue; - if (entry.type === "assistant" || entry.type === "user" || entry.type === "system" || entry.type === "result") { - return false; - } - if (typeof entry.type === "string" && sdkEventTypes.has(entry.type) && typeof entry.data === "object" && entry.data !== null) { - sdkLikeCount++; - } - } - - return sdkLikeCount > 0; -} - -/** - * Converts Copilot SDK events.jsonl entries into the normalized trace format - * expected by generateConversationMarkdown and copilot-style summary renderers. - * @param {Array} sdkEntries - * @returns {Array} - */ -function normalizeCopilotSdkEventsToTrace(sdkEntries) { - /** @type {Array} */ - const normalizedEntries = []; - const toolNames = new Set(); - const pendingByToolCallId = new Map(); - const pendingIdsByToolName = new Map(); - let toolCounter = 0; - let turnCount = 0; - let assistantMessageCount = 0; - let firstTimestampMs = null; - let lastTimestampMs = null; - - const addPendingId = (toolName, toolId) => { - const existing = pendingIdsByToolName.get(toolName); - if (existing) { - existing.push(toolId); - return; - } - pendingIdsByToolName.set(toolName, [toolId]); - }; - - const shiftPendingId = toolName => { - const existing = pendingIdsByToolName.get(toolName); - if (!existing || existing.length === 0) return null; - const toolId = existing.shift(); - if (existing.length === 0) { - pendingIdsByToolName.delete(toolName); - } - return toolId || null; - }; - - const removePendingId = (toolName, toolId) => { - const existing = pendingIdsByToolName.get(toolName); - if (!existing || existing.length === 0) return; - const idx = existing.indexOf(toolId); - if (idx === -1) return; - existing.splice(idx, 1); - if (existing.length === 0) { - pendingIdsByToolName.delete(toolName); - } - }; - - const normalizeToolName = (rawToolName, mcpServerName) => { - const toolName = typeof rawToolName === "string" && rawToolName.trim() ? rawToolName.trim() : "unknown"; - if (toolName.startsWith("mcp__")) { - return toolName; - } - const serverName = typeof mcpServerName === "string" ? mcpServerName.trim() : ""; - if (!serverName) { - return toolName; - } - return `mcp__${serverName}__${toolName}`; - }; - - const maybeTrackTimestamp = timestamp => { - if (typeof timestamp !== "string") return; - const ms = new Date(timestamp).getTime(); - if (!Number.isFinite(ms)) return; - if (firstTimestampMs === null || ms < firstTimestampMs) { - firstTimestampMs = ms; - } - if (lastTimestampMs === null || ms > lastTimestampMs) { - lastTimestampMs = ms; - } - }; - - for (const entry of sdkEntries) { - if (!entry || typeof entry !== "object") continue; - maybeTrackTimestamp(entry.timestamp); - - switch (entry.type) { - case "user.message": - turnCount++; - break; - - case "assistant.message": { - assistantMessageCount++; - const text = typeof entry.data?.content === "string" ? entry.data.content.trim() : ""; - if (!text) break; - normalizedEntries.push({ - type: "assistant", - message: { - content: [{ type: "text", text }], - }, - }); - break; - } - - case "tool.execution_start": { - const toolName = normalizeToolName(entry.data?.toolName, entry.data?.mcpServerName); - toolNames.add(toolName); - const toolCallId = typeof entry.data?.toolCallId === "string" && entry.data.toolCallId.trim() ? entry.data.toolCallId : null; - const resolvedToolId = toolCallId || `sdk_tool_${++toolCounter}`; - if (toolCallId) { - pendingByToolCallId.set(toolCallId, resolvedToolId); - } - addPendingId(toolName, resolvedToolId); - normalizedEntries.push({ - type: "assistant", - message: { - content: [{ type: "tool_use", id: resolvedToolId, name: toolName, input: {} }], - }, - }); - break; - } - - case "tool.execution_complete": { - const toolName = normalizeToolName(entry.data?.toolName, entry.data?.mcpServerName); - toolNames.add(toolName); - const toolCallId = typeof entry.data?.toolCallId === "string" && entry.data.toolCallId.trim() ? entry.data.toolCallId : null; - let resolvedToolId = null; - - if (toolCallId && pendingByToolCallId.has(toolCallId)) { - resolvedToolId = pendingByToolCallId.get(toolCallId); - pendingByToolCallId.delete(toolCallId); - if (resolvedToolId) { - removePendingId(toolName, resolvedToolId); - } - } - if (!resolvedToolId) { - resolvedToolId = shiftPendingId(toolName); - } - if (!resolvedToolId) { - resolvedToolId = `sdk_tool_${++toolCounter}`; - normalizedEntries.push({ - type: "assistant", - message: { - content: [{ type: "tool_use", id: resolvedToolId, name: toolName, input: {} }], - }, - }); - } - - const success = typeof entry.data?.success === "boolean" ? entry.data.success : !entry.data?.error; - normalizedEntries.push({ - type: "user", - message: { - content: [ - { - type: "tool_result", - tool_use_id: resolvedToolId, - content: success ? "success" : "Tool execution failed", - is_error: !success, - }, - ], - }, - }); - break; - } - - default: - break; - } - } - - if (normalizedEntries.length === 0) { - return []; - } - - normalizedEntries.unshift({ - type: "system", - subtype: "init", - model: "copilot-sdk", - session_id: null, - tools: Array.from(toolNames), - }); - - const resultEntry = { - type: "result", - num_turns: turnCount > 0 ? turnCount : assistantMessageCount, - }; - if (firstTimestampMs !== null && lastTimestampMs !== null && lastTimestampMs >= firstTimestampMs) { - resultEntry.duration_ms = lastTimestampMs - firstTimestampMs; - } - normalizedEntries.push(resultEntry); - - return normalizedEntries; + return isCopilotEventLogEntries(logEntries); } /** @@ -310,15 +122,26 @@ function parseCopilotLog(logContent) { return { markdown: "## Agent Log Summary\n\nLog format not recognized as Copilot JSON array or JSONL.\n", logEntries: [] }; } - if (isCopilotSdkEventsFormat(logEntries)) { - const normalized = normalizeCopilotSdkEventsToTrace(logEntries); - if (normalized.length > 0) { - logEntries = normalized; - } + const isEventFormat = isCopilotSdkEventsFormat(logEntries); + let canonicalLogEntries = isEventFormat ? logEntries : convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "copilot" }); + const legacyRenderEntries = isEventFormat ? convertCopilotEventsToLegacyLogEntries(canonicalLogEntries) : logEntries; + if (isEventFormat && !canonicalLogEntries.some(entry => entry?.type === "session.result")) { + const legacyResult = legacyRenderEntries.find(entry => entry?.type === "result"); + canonicalLogEntries.push({ + type: "session.result", + data: { + numTurns: legacyResult?.num_turns, + durationMs: legacyResult?.duration_ms, + totalCostUsd: legacyResult?.total_cost_usd, + usage: legacyResult?.usage, + errors: legacyResult?.errors, + permissionDenials: legacyResult?.permission_denials, + }, + }); } // Generate conversation markdown using shared function - const conversationResult = generateConversationMarkdown(logEntries, { + const conversationResult = generateConversationMarkdown(canonicalLogEntries, { formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: true }), formatInitCallback: initEntry => formatInitializationSummary(initEntry, { @@ -364,7 +187,7 @@ function parseCopilotLog(logContent) { }); let markdown = conversationResult.markdown; - const awfTokenWarnings = extractAwfTokenWarnings(logEntries); + const awfTokenWarnings = extractAwfTokenWarnings(canonicalLogEntries); if (awfTokenWarnings.length > 0) { markdown += "## ⚠️ Firewall Steering\n\n"; @@ -375,14 +198,13 @@ function parseCopilotLog(logContent) { } // Add Information section - const lastEntry = logEntries[logEntries.length - 1]; - const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); + const lastEntry = legacyRenderEntries[legacyRenderEntries.length - 1]; markdown += generateInformationSection(lastEntry, { additionalInfoCallback: () => "", }); - return { markdown, logEntries }; + return { markdown, logEntries: canonicalLogEntries }; } /** diff --git a/setup/js/parse_gemini_log.cjs b/setup/js/parse_gemini_log.cjs index 30b38a0..9c3615e 100644 --- a/setup/js/parse_gemini_log.cjs +++ b/setup/js/parse_gemini_log.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse } = require("./log_parser_shared.cjs"); +const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse, convertLegacyLogEntriesToCopilotEvents } = require("./log_parser_shared.cjs"); const main = createEngineLogParser({ parserName: "Gemini", @@ -61,7 +61,8 @@ function parseGeminiLog(logContent) { const resultEntry = rawEntries.find(e => e.type === "result"); // Generate conversation markdown using shared function - const conversationResult = generateConversationMarkdown(logEntries, { + const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "gemini" }); + const conversationResult = generateConversationMarkdown(canonicalLogEntries, { formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: false }), formatInitCallback: initEntry => formatInitializationSummary(initEntry, { includeSlashCommands: false }), }); @@ -87,7 +88,7 @@ function parseGeminiLog(logContent) { return { markdown, - logEntries, + logEntries: canonicalLogEntries, mcpFailures: [], maxTurnsHit: false, }; diff --git a/setup/js/parse_pi_log.cjs b/setup/js/parse_pi_log.cjs index 31f3cf2..52d539a 100644 --- a/setup/js/parse_pi_log.cjs +++ b/setup/js/parse_pi_log.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse } = require("./log_parser_shared.cjs"); +const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse, convertLegacyLogEntriesToCopilotEvents } = require("./log_parser_shared.cjs"); const main = createEngineLogParser({ parserName: "Pi", @@ -57,7 +57,8 @@ function parsePiLog(logContent) { const resultEntry = rawEntries.find(e => e.type === "result"); - const conversationResult = generateConversationMarkdown(logEntries, { + const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "pi" }); + const conversationResult = generateConversationMarkdown(canonicalLogEntries, { formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: false }), formatInitCallback: initEntry => formatInitializationSummary(initEntry, { includeSlashCommands: false }), }); @@ -81,7 +82,7 @@ function parsePiLog(logContent) { return { markdown, - logEntries, + logEntries: canonicalLogEntries, mcpFailures: [], maxTurnsHit: false, }; diff --git a/setup/js/safe_output_type_validator.cjs b/setup/js/safe_output_type_validator.cjs index d9de735..7f47cb7 100644 --- a/setup/js/safe_output_type_validator.cjs +++ b/setup/js/safe_output_type_validator.cjs @@ -409,6 +409,15 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) } if (validation.type === "array") { + // Backward compatibility: create_issue agents sometimes provide comma-separated labels as a string. + // Normalize this into a string array before strict array validation. + if (itemType === "create_issue" && fieldName === "labels" && typeof value === "string") { + value = value + .split(",") + .map(item => item.trim()) + .filter(Boolean); + } + if (!Array.isArray(value)) { // For required fields, use "requires a" format for both missing and wrong type if (validation.required) { diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 8746d17..60d9d49 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -1,7 +1,7 @@ [ { "name": "create_issue", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 required fields (title, body) are listed in this schema; if you are not ready to open the real issue, call `noop` instead. Creates a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. Compatibility: labels may be passed as either an array of strings or a comma-separated string; string input is split, trimmed, and normalized to an array.", "inputSchema": { "type": "object", "required": ["title", "body"], @@ -16,11 +16,11 @@ "description": "Detailed issue description in Markdown. Must be the final intended body \u2014 not a placeholder or test value. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate." }, "labels": { - "type": "array", + "type": ["array", "string"], "items": { "type": "string" }, - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository." + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository. Accepts either an array of strings or a comma-separated string; string input is split on commas, trimmed, and normalized to an array." }, "fields": { "type": "array", diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index 83793c3..afd8563 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -1282,7 +1282,8 @@ async function sendJobSetupSpan(options = {}) { const runnerArch = process.env.RUNNER_ARCH || ""; const runnerName = process.env.RUNNER_NAME || ""; const runnerEnvironment = process.env.RUNNER_ENVIRONMENT || ""; - const scopeVersion = process.env.GH_AW_INFO_VERSION || process.env.GH_AW_INFO_CLI_VERSION || process.env.GITHUB_SHA || "unknown"; + const scopeVersion = + (typeof awInfo.cli_version === "string" ? awInfo.cli_version : "") || process.env.GH_AW_INFO_CLI_VERSION || awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || process.env.GITHUB_SHA || "unknown"; const attributes = [ buildAttr("gh-aw.job.name", jobName), @@ -1937,7 +1938,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { const awInfo = readJSONIfExists("/tmp/gh-aw/aw_info.json") || {}; const serviceName = process.env.OTEL_SERVICE_NAME || "gh-aw"; - const version = awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || awInfo.cli_version || process.env.GH_AW_INFO_CLI_VERSION || process.env.GITHUB_SHA || "unknown"; + const version = (typeof awInfo.cli_version === "string" ? awInfo.cli_version : "") || process.env.GH_AW_INFO_CLI_VERSION || awInfo.agent_version || awInfo.version || process.env.GH_AW_INFO_VERSION || process.env.GITHUB_SHA || "unknown"; // Prefer GITHUB_AW_OTEL_TRACE_ID (written to GITHUB_ENV by this job's setup step) so // all spans in the same job share one trace. Fall back to aw_context.otel_trace_id diff --git a/setup/js/set_issue_field.cjs b/setup/js/set_issue_field.cjs index 73928fb..292c553 100644 --- a/setup/js/set_issue_field.cjs +++ b/setup/js/set_issue_field.cjs @@ -41,74 +41,50 @@ async function getIssueNodeId(githubClient, owner, repo, issueNumber) { * @returns {Promise}>>} */ async function fetchIssueFields(githubClient, owner, repo) { - try { - const result = await githubClient.graphql( - `query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - issueFields(first: 100) { - nodes { - __typename - id - name - ... on IssueFieldSingleSelect { - options { - id - name - } - } - } - } - owner { + const result = await githubClient.graphql( + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issueFields(first: 100) { + nodes { __typename - ... on Organization { - issueFields(first: 100) { - nodes { - __typename - id - name - ... on IssueFieldSingleSelect { - options { - id - name - } - } - } - } - } - ... on User { - issueFields(first: 100) { - nodes { - __typename - id - name - ... on IssueFieldSingleSelect { - options { - id - name - } - } - } + ... on IssueField { id name } + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldDate { id name } + ... on IssueFieldSingleSelect { id name options { id name } } + ... on IssueFieldMultiSelect { id name options { id name } } + } + } + owner { + __typename + ... on Organization { + issueFields(first: 100) { + nodes { + __typename + ... on IssueField { id name } + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldDate { id name } + ... on IssueFieldSingleSelect { id name options { id name } } + ... on IssueFieldMultiSelect { id name options { id name } } } } } } - }`, - { owner, repo } - ); + } + }`, + { owner, repo } + ); - const repoFields = result?.repository?.issueFields?.nodes ?? []; - if (repoFields.length > 0) { - return repoFields; - } + const isValidNode = node => typeof node?.id === "string" && typeof node?.name === "string"; - const ownerFields = result?.repository?.owner?.issueFields?.nodes ?? []; - return ownerFields; - } catch (error) { - if (typeof core !== "undefined") { - core.debug(`Could not fetch issue fields (may not be enabled): ${error instanceof Error ? error.message : String(error)}`); - } - return []; + const repoFields = (result?.repository?.issueFields?.nodes ?? []).filter(isValidNode); + if (repoFields.length > 0) { + return repoFields; } + + const ownerFields = (result?.repository?.owner?.issueFields?.nodes ?? []).filter(isValidNode); + return ownerFields; } /** diff --git a/setup/js/start_mcp_gateway.cjs b/setup/js/start_mcp_gateway.cjs index 6bdb7d9..43c487c 100644 --- a/setup/js/start_mcp_gateway.cjs +++ b/setup/js/start_mcp_gateway.cjs @@ -208,6 +208,58 @@ function assertNotSymlink(p) { } } +/** + * Resolves the Copilot CLI config directory and the MCP config file path from + * the runtime $HOME. The Copilot CLI uses ~/.copilot, which is + * /home/runner/.copilot on standard GitHub-hosted runners (HOME=/home/runner) + * but may differ on self-hosted or containerized runners. HOME is a standard + * POSIX environment variable inherited from the runner's parent process and + * passed through to shell steps; other generators (copilot_mcp.go, + * copilot_engine_execution.go) rely on it the same way. + * + * Exported for testability; throws Error rather than exiting so tests can + * exercise the missing-HOME branch. + * + * @returns {{ dir: string, file: string }} + */ +function resolveCopilotConfigPaths() { + const home = process.env.HOME; + if (!home) { + throw new Error("HOME environment variable is not set; cannot locate Copilot CLI config directory"); + } + const dir = path.join(home, ".copilot"); + return { dir, file: path.join(dir, "mcp-config.json") }; +} + +/** + * Determines which engine-specific MCP config converter to use. + * Copilot auto-detection consults HOME only when HOME is available so + * explicit non-Copilot engines do not fail just because HOME is unset. + * + * @param {string} configDir + * @param {NodeJS.ProcessEnv} [env] + * @param {(p: string) => boolean} [existsSync] + * @returns {string} + */ +function detectEngineType(configDir, env = process.env, existsSync = fs.existsSync) { + if (env.GH_AW_ENGINE) { + return env.GH_AW_ENGINE; + } + if (env.GITHUB_COPILOT_CLI_MODE) { + return "copilot"; + } + if (env.HOME && existsSync(path.join(env.HOME, ".copilot"))) { + return "copilot"; + } + if (existsSync(path.join(configDir, "config.toml"))) { + return "codex"; + } + if (existsSync(path.join(configDir, "mcp-servers.json"))) { + return "claude"; + } + return "unknown"; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -671,18 +723,7 @@ async function main() { } // Determine engine type - let engineType = process.env.GH_AW_ENGINE || ""; - if (!engineType) { - if (fs.existsSync("/home/runner/.copilot") || process.env.GITHUB_COPILOT_CLI_MODE) { - engineType = "copilot"; - } else if (fs.existsSync(path.join(configDir, "config.toml"))) { - engineType = "codex"; - } else if (fs.existsSync(path.join(configDir, "mcp-servers.json"))) { - engineType = "claude"; - } else { - engineType = "unknown"; - } - } + const engineType = detectEngineType(configDir); core.info(`Detected engine type: ${engineType}`); @@ -699,10 +740,17 @@ async function main() { const converterPath = path.join(runnerTemp || "", "gh-aw/actions", converterFile); execSync(`node "${converterPath}"`, { stdio: "inherit", env: process.env }); } else { + let copilotConfigDir, copilotConfigFile; + try { + ({ dir: copilotConfigDir, file: copilotConfigFile } = resolveCopilotConfigPaths()); + } catch (err) { + core.error(`ERROR: ${err.message}`); + process.exit(1); + } core.info(`No agent-specific converter found for engine: ${engineType}`); core.info("Using gateway output directly"); // Default fallback – copy to most common location, filtering CLI-mounted servers - fs.mkdirSync("/home/runner/.copilot", { recursive: true }); + fs.mkdirSync(copilotConfigDir, { recursive: true }); const cliServersRaw = process.env.GH_AW_MCP_CLI_SERVERS; if (cliServersRaw) { try { @@ -716,16 +764,16 @@ async function main() { } } } - fs.writeFileSync("/home/runner/.copilot/mcp-config.json", JSON.stringify(filtered, null, 2), { mode: 0o600 }); + fs.writeFileSync(copilotConfigFile, JSON.stringify(filtered, null, 2), { mode: 0o600 }); } catch { core.error("ERROR: Failed to filter CLI-mounted servers from agent MCP config"); core.info("Falling back to unfiltered config"); - fs.copyFileSync(outputPath, "/home/runner/.copilot/mcp-config.json"); + fs.copyFileSync(outputPath, copilotConfigFile); } } else { - fs.copyFileSync(outputPath, "/home/runner/.copilot/mcp-config.json"); + fs.copyFileSync(outputPath, copilotConfigFile); } - core.info(fs.readFileSync("/home/runner/.copilot/mcp-config.json", "utf8")); + core.info(fs.readFileSync(copilotConfigFile, "utf8")); } printTiming(configConvertStart, "Configuration conversion"); core.info(""); @@ -842,7 +890,9 @@ if (require.main === module) { module.exports = { applyOTLPIgnoreIfMissing, + detectEngineType, getOTLPIfMissingMode, hasNonEmptyOTLPHeaders, isOTLPIfMissingIgnore, + resolveCopilotConfigPaths, }; diff --git a/setup/js/validate_context_variables.cjs b/setup/js/validate_context_variables.cjs index 2f3b78c..45481ac 100644 --- a/setup/js/validate_context_variables.cjs +++ b/setup/js/validate_context_variables.cjs @@ -45,7 +45,6 @@ * not a numeric database ID. Push events always produce a hex SHA here. */ -const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); /** @@ -146,58 +145,44 @@ function validateNumericValue(value, varName) { * @param {object} ctx - GitHub Actions context object */ async function validateContextVariables(coreArg, ctx) { - try { - coreArg.info("Starting context variable validation..."); - - const failures = []; - let checkedCount = 0; - - // Validate each numeric context variable by reading directly from context - for (const { path, name } of NUMERIC_CONTEXT_PATHS) { - const value = getNestedValue(ctx, path); - - // Only validate if the value exists - if (value !== undefined) { - checkedCount++; - const result = validateNumericValue(value, name); - - if (result.valid) { - coreArg.info(`✓ ${result.message}`); - } else { - coreArg.error(`✗ ${result.message}`); - failures.push({ - name, - value, - message: result.message, - }); - } + coreArg.info("Starting context variable validation..."); + + const failures = []; + let checkedCount = 0; + + for (const { path, name } of NUMERIC_CONTEXT_PATHS) { + const value = getNestedValue(ctx, path); + if (value !== undefined) { + checkedCount++; + const result = validateNumericValue(value, name); + if (result.valid) { + coreArg.info(`✓ ${result.message}`); + } else { + coreArg.error(`✗ ${result.message}`); + failures.push({ name, value, message: result.message }); } } + } - coreArg.info(`Validated ${checkedCount} context variables`); - - // If there are any failures, fail the workflow - if (failures.length > 0) { - const errorMessage = - `Context variable validation failed!\n\n` + - `Found ${failures.length} malicious or invalid numeric field(s):\n\n` + - failures.map(f => ` - ${f.name}: "${f.value}"\n ${f.message}`).join("\n\n") + - "\n\n" + - "Numeric context variables (like github.event.issue.number) must be either empty or valid integers.\n" + - "This validation prevents injection attacks where special text or code is hidden in numeric fields.\n\n" + - "If you believe this is a false positive, please report it at:\n" + - "https://github.com/github/gh-aw/issues"; - - coreArg.setFailed(errorMessage); - throw new Error(errorMessage); - } - - coreArg.info("✅ All context variables validated successfully"); - } catch (error) { - const errorMessage = getErrorMessage(error); - coreArg.setFailed(`${ERR_VALIDATION}: Context variable validation failed: ${errorMessage}`); - throw error; + coreArg.info(`Validated ${checkedCount} context variables`); + + if (failures.length > 0) { + const failureDetails = failures.map(f => ` - ${f.name}: "${f.value}"\n ${f.message}`).join("\n\n"); + const errorMessage = + `${ERR_VALIDATION}: Context variable validation failed!\n\n` + + `Found ${failures.length} malicious or invalid numeric field(s):\n\n` + + failureDetails + + "\n\n" + + "Numeric context variables (like github.event.issue.number) must be either empty or valid integers.\n" + + "This validation prevents injection attacks where special text or code is hidden in numeric fields.\n\n" + + "If you believe this is a false positive, please report it at:\n" + + "https://github.com/github/gh-aw/issues"; + + coreArg.setFailed(errorMessage); + throw new Error(errorMessage); } + + coreArg.info("✅ All context variables validated successfully"); } /** diff --git a/setup/post.js b/setup/post.js index 6fbd548..2834ca9 100644 --- a/setup/post.js +++ b/setup/post.js @@ -13,6 +13,75 @@ const path = require("path"); const { spawnSync } = require("child_process"); const fs = require("fs"); +function isDebugModeEnabled() { + const toBool = (value) => { + const normalized = String(value || "").toLowerCase(); + return normalized === "1" || normalized === "true"; + }; + return toBool(process.env.RUNNER_DEBUG) || toBool(process.env.ACTIONS_STEP_DEBUG); +} + +function listTmpGhAwFiles(tmpDir, maxDepth, maxFiles) { + if (!fs.existsSync(tmpDir)) { + console.log(`[debug] ${tmpDir} does not exist; skipping file listing`); + return; + } + + const files = []; + let readErrors = 0; + + const walk = (currentDir, depth) => { + if (depth >= maxDepth || files.length >= maxFiles) { + return; + } + + let entries; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (err) { + readErrors += 1; + console.log(`[debug] failed to read ${currentDir}: ${err.message}`); + return; + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (files.length >= maxFiles) { + return; + } + + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, depth + 1); + continue; + } + + files.push(path.relative(tmpDir, fullPath) || "."); + } + }; + + walk(tmpDir, 0); + + const truncated = files.length >= maxFiles; + console.log( + `[debug] listing files under ${tmpDir} (max depth ${maxDepth}, max files ${maxFiles})`, + ); + if (files.length === 0) { + console.log("[debug] no files found"); + } else { + for (const file of files) { + console.log(`[debug] - ${file}`); + } + } + if (truncated) { + console.log(`[debug] output truncated at ${maxFiles} files`); + } + if (readErrors > 0) { + console.log(`[debug] encountered ${readErrors} directory read error(s)`); + } +} + // Wrap everything in an async IIFE so that the OTLP span is fully sent before // the cleanup deletes /tmp/gh-aw/ (which contains aw_info.json and otel.jsonl). (async () => { @@ -28,6 +97,12 @@ const fs = require("fs"); } const tmpDir = "/tmp/gh-aw"; + const maxDebugDepth = 4; + const maxDebugFiles = 200; + + if (isDebugModeEnabled()) { + listTmpGhAwFiles(tmpDir, maxDebugDepth, maxDebugFiles); + } console.log(`Cleaning up ${tmpDir}...`); diff --git a/setup/sh/convert_gateway_config_copilot.sh b/setup/sh/convert_gateway_config_copilot.sh index add43b6..190043d 100755 --- a/setup/sh/convert_gateway_config_copilot.sh +++ b/setup/sh/convert_gateway_config_copilot.sh @@ -38,6 +38,15 @@ if [ -z "$MCP_GATEWAY_PORT" ]; then exit 1 fi +if [ -z "${HOME:-}" ]; then + echo "ERROR: HOME environment variable is required" + exit 1 +fi + +COPILOT_CONFIG_DIR="$HOME/.copilot" +COPILOT_CONFIG_PATH="$COPILOT_CONFIG_DIR/mcp-config.json" +mkdir -p "$COPILOT_CONFIG_DIR" + echo "Converting gateway configuration to Copilot format..." echo "Input: $MCP_GATEWAY_OUTPUT" echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT" @@ -88,15 +97,15 @@ jq --arg urlPrefix "$URL_PREFIX" ' .url |= (. | sub("^http://[^/]+/mcp/"; $urlPrefix + "/mcp/")) ) ) -' "$MCP_GATEWAY_OUTPUT" > /home/runner/.copilot/mcp-config.json +' "$MCP_GATEWAY_OUTPUT" > "$COPILOT_CONFIG_PATH" # Restrict permissions so only the runner process owner can read this file. # mcp-config.json contains the bearer token for the MCP gateway; an attacker # who reads it could bypass the --allowed-tools constraint by issuing raw # JSON-RPC calls directly to the gateway. -chmod 600 /home/runner/.copilot/mcp-config.json +chmod 600 "$COPILOT_CONFIG_PATH" -echo "Copilot configuration written to /home/runner/.copilot/mcp-config.json" +echo "Copilot configuration written to $COPILOT_CONFIG_PATH" echo "" echo "Converted configuration:" -cat /home/runner/.copilot/mcp-config.json +cat "$COPILOT_CONFIG_PATH" diff --git a/setup/sh/start_mcp_gateway.sh b/setup/sh/start_mcp_gateway.sh index e3e3aeb..410a6cf 100755 --- a/setup/sh/start_mcp_gateway.sh +++ b/setup/sh/start_mcp_gateway.sh @@ -376,11 +376,20 @@ if [ -z "$MCP_GATEWAY_API_KEY" ]; then exit 1 fi +require_home_for_copilot_config() { + if [ -z "${HOME:-}" ]; then + echo "ERROR: HOME environment variable must be set before using Copilot MCP config paths" + exit 1 + fi +} + # Determine which agent-specific converter to use based on engine type # Check for engine-specific indicators and call appropriate converter if [ -n "$GH_AW_ENGINE" ]; then ENGINE_TYPE="$GH_AW_ENGINE" -elif [ -f "/home/runner/.copilot" ] || [ -n "$GITHUB_COPILOT_CLI_MODE" ]; then +elif [ -n "$GITHUB_COPILOT_CLI_MODE" ]; then + ENGINE_TYPE="copilot" +elif [ -n "${HOME:-}" ] && [ -d "$HOME/.copilot" ]; then ENGINE_TYPE="copilot" elif [ -f "/tmp/gh-aw/mcp-config/config.toml" ]; then ENGINE_TYPE="codex" @@ -417,19 +426,20 @@ case "$ENGINE_TYPE" in echo "No agent-specific converter found for engine: $ENGINE_TYPE" echo "Using gateway output directly" # Default fallback - copy to most common location, filtering out CLI-mounted servers - mkdir -p /home/runner/.copilot + require_home_for_copilot_config + mkdir -p "$HOME/.copilot" if [ -n "$GH_AW_MCP_CLI_SERVERS" ]; then if ! jq --argjson cliServers "$GH_AW_MCP_CLI_SERVERS" \ '.mcpServers |= with_entries(select(.key | IN($cliServers[]) | not))' \ - /tmp/gh-aw/mcp-config/gateway-output.json > /home/runner/.copilot/mcp-config.json; then + /tmp/gh-aw/mcp-config/gateway-output.json > "$HOME/.copilot/mcp-config.json"; then echo "ERROR: Failed to filter CLI-mounted servers from agent MCP config" echo "Falling back to unfiltered config" - cp /tmp/gh-aw/mcp-config/gateway-output.json /home/runner/.copilot/mcp-config.json + cp /tmp/gh-aw/mcp-config/gateway-output.json "$HOME/.copilot/mcp-config.json" fi else - cp /tmp/gh-aw/mcp-config/gateway-output.json /home/runner/.copilot/mcp-config.json + cp /tmp/gh-aw/mcp-config/gateway-output.json "$HOME/.copilot/mcp-config.json" fi - cat /home/runner/.copilot/mcp-config.json + cat "$HOME/.copilot/mcp-config.json" ;; esac print_timing $CONFIG_CONVERT_START "Configuration conversion"