// changes to tool permissions should be reflected in wiki/granular-tools.md import * as core from "@actions/core"; import { initToolState, startMcpHttpServer, type ToolContext, type ToolState, } from "./mcp/server.ts"; import { computeModes } from "./modes.ts"; import { type ActivityTimeout, createProcessOutputActivityTimeout, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS, } from "./utils/activity.ts"; import { resolveAgent } from "./utils/agent.ts"; import { apiFetch } from "./utils/apiFetch.ts"; import { validateAgentApiKey } from "./utils/apiKeys.ts"; import { resolveBody } from "./utils/body.ts"; import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts"; import { reportErrorToComment } from "./utils/errorReport.ts"; import { onExitSignal } from "./utils/exitHandler.ts"; import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts"; import { startGitAuthServer } from "./utils/gitAuthServer.ts"; import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts"; import { resolveInstructions } from "./utils/instructions.ts"; import { executeLifecycleHook } from "./utils/lifecycle.ts"; import { normalizeEnv } from "./utils/normalizeEnv.ts"; import { resolvePayload, resolvePromptInput } from "./utils/payload.ts"; import { postReviewCleanup } from "./utils/reviewCleanup.ts"; import { handleAgentResult } from "./utils/run.ts"; import { resolveRunContextData } from "./utils/runContextData.ts"; import { createTempDirectory, setupGit } from "./utils/setup.ts"; import { killTrackedChildren } from "./utils/subprocess.ts"; import { parseTimeString, TIMEOUT_DISABLED } from "./utils/time.ts"; import { Timer } from "./utils/timer.ts"; import { getJobToken, resolveTokens } from "./utils/token.ts"; import { resolveRun } from "./utils/workflow.ts"; export { Inputs } from "./utils/payload.ts"; export interface MainResult { success: boolean; output?: string | undefined; error?: string | undefined; result?: string | undefined; } function resolveOutputSchema(): Record | undefined { const raw = core.getInput("output_schema"); if (!raw) return undefined; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`invalid output_schema: not valid JSON`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`invalid output_schema: must be a JSON object`); } log.info("» structured output schema provided — output will be required"); return parsed as Record; } import type { ResolvedPayload } from "./utils/payload.ts"; interface OidcCredentials { requestUrl: string; requestToken: string; } async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise { try { process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken; const oidcToken = await core.getIDToken("pullfrog-api"); delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const response = await apiFetch({ path: "/api/proxy-token", method: "POST", headers: { Authorization: `Bearer ${oidcToken}` }, }); if (!response.ok) { log.warning(`proxy key mint failed (${response.status})`); return null; } const data = (await response.json()) as { key: string }; return data.key; } catch (error) { log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`); return null; } finally { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } } async function resolveProxyModel(ctx: { payload: ResolvedPayload; oss: boolean; proxyModel?: string | undefined; oidcCredentials: OidcCredentials | null; }): Promise { // env override = BYOK escape hatch, don't proxy if (process.env.PULLFROG_MODEL?.trim()) return; // OSS: server decided the model if (ctx.oss && ctx.proxyModel) { if (!ctx.oidcCredentials) { log.warning("» oss repo but no OIDC credentials available — skipping proxy"); return; } const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); if (!key) return; process.env.OPENROUTER_API_KEY = key; core.setSecret(key); ctx.payload.proxyModel = ctx.proxyModel; log.info(`» proxy: oss → ${ctx.proxyModel}`); return; } // managed billing will add its path here later } async function writeJobSummary(toolState: ToolState): Promise { const usageSummary = formatUsageSummary(toolState.usageEntries); const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean); if (summaryParts.length > 0) { await writeSummary(summaryParts.join("\n\n")); } } export async function main(): Promise { // normalize env var names to uppercase (handles case-insensitive workflow files) normalizeEnv(); // write usage summary on SIGINT/SIGTERM so the worker can read it after sandbox.exec const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH; if (usageSummaryPath) { onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath)); } const timer = new Timer(); let activityTimeout: ActivityTimeout | null = null; // parse prompt early to extract progressCommentId for toolState const resolvedPromptInput = resolvePromptInput(); const toolState = initToolState({ progressCommentId: typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined, }); // resolve and fingerprint git binary before any agent code runs resolveGit(); // get job token for initial API calls const jobToken = getJobToken(); const initialOctokit = createOctokit(jobToken); const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken }); timer.checkpoint("runContextData"); // resolve payload to determine shell permission const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings); toolState.model = payload.model; // resolve tokens first — acquireNewToken needs OIDC env vars for token exchange await using tokenRef = await resolveTokens({ push: payload.push }); // stash OIDC credentials in memory before wiping from process.env // the agent's shell commands can't access JS variables, so this is safe const oidcCredentials: OidcCredentials | null = process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ? { requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, } : null; // clear OIDC env vars in restricted mode to prevent agent from minting tokens if (payload.shell !== "enabled") { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } // proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing) await resolveProxyModel({ payload, oss: runContext.oss, proxyModel: runContext.proxyModel, oidcCredentials, }); // create octokit with MCP token for GitHub API calls const octokit = createOctokit(tokenRef.mcpToken); const runInfo = await resolveRun({ octokit }); let toolContext: ToolContext | undefined; try { if (payload.cwd && process.cwd() !== payload.cwd) { process.chdir(payload.cwd); } // resolve body - fetches body_html and converts to markdown if images present // this ensures agents receive markdown with working signed image URLs const originalBody = payload.event.body; const resolvedBody = await resolveBody({ event: payload.event, octokit, repo: runContext.repo, }); if (resolvedBody !== originalBody) { payload.event.body = resolvedBody; // also update prompt if original body was included there if (originalBody && payload.prompt.includes(originalBody)) { payload.prompt = payload.prompt.replace(originalBody, resolvedBody ?? ""); } } const tmpdir = createTempDirectory(); await using gitAuthServer = await startGitAuthServer(tmpdir); setGitAuthServer(gitAuthServer); const agent = resolveAgent(); validateAgentApiKey({ agent, model: payload.proxyModel ?? payload.model, owner: runContext.repo.owner, name: runContext.repo.name, }); await setupGit({ gitToken: tokenRef.gitToken, owner: runContext.repo.owner, name: runContext.repo.name, octokit, toolState, shell: payload.shell, postCheckoutScript: runContext.repoSettings.postCheckoutScript, }); timer.checkpoint("git"); // execute setup lifecycle hook (runs once at initialization) await executeLifecycleHook({ event: "setup", script: runContext.repoSettings.setupScript, }); timer.checkpoint("lifecycleHooks::setup"); const modes = [...computeModes(), ...runContext.repoSettings.modes]; const outputSchema = resolveOutputSchema(); // mcpServerUrl and tmpdir are set after server starts toolContext = { repo: runContext.repo, payload, octokit, githubInstallationToken: tokenRef.mcpToken, gitToken: tokenRef.gitToken, apiToken: runContext.apiToken, modes, postCheckoutScript: runContext.repoSettings.postCheckoutScript, prApproveEnabled: runContext.repoSettings.prApproveEnabled, modeInstructions: runContext.repoSettings.modeInstructions, toolState, runId: runInfo.runId, jobId: runInfo.jobId, mcpServerUrl: "", tmpdir, }; await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema }); toolContext.mcpServerUrl = mcpHttpServer.url; log.info(`» MCP server started at ${mcpHttpServer.url}`); timer.checkpoint("mcpServer"); const instructions = resolveInstructions({ payload, repo: runContext.repo, modes, outputSchema, }); // log instructions as soon as they are fully resolved const logParts = [ instructions.eventInstructions ? `EVENT-LEVEL INSTRUCTIONS:\n${instructions.eventInstructions}` : null, instructions.user ? `USER REQUEST:\n${instructions.user}` : null, instructions.event, ].filter(Boolean); log.box(logParts.join("\n\n---\n\n"), { title: "Instructions", }); // run agent, optionally with timeout enforcement activityTimeout = createProcessOutputActivityTimeout({ timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS, checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, }); activityTimeout.promise.catch(() => {}); // prevent unhandled rejection if agent wins race const agentPromise = agent.run({ payload, mcpServerUrl: mcpHttpServer.url, tmpdir, instructions, }); // timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt: // - --timeout=2h (or any duration like "--timeout=30m", "--timeout=1h30m") to set a custom timeout // - --notimeout to disable timeout entirely let result: Awaited; if (payload.timeout === TIMEOUT_DISABLED) { result = await Promise.race([agentPromise, activityTimeout.promise]); } else { const parsed = payload.timeout ? parseTimeString(payload.timeout) : null; if (payload.timeout && parsed === null) { log.warning(`invalid timeout format "${payload.timeout}", using default 1h`); } const timeoutMs = parsed ?? 3600000; const actualTimeout = parsed !== null ? payload.timeout : "1h"; let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`agent run timed out after ${actualTimeout}`)); }, timeoutMs); }); timeoutPromise.catch(() => {}); // prevent unhandled rejection if agent wins race try { result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]); } finally { clearTimeout(timeoutId); } } // accumulate top-level agent usage if (result.usage) { toolState.usageEntries.push(result.usage); } // validate this before writing job summary to avoid masking the error if (outputSchema && !toolState.output) { throw new Error( "output_schema was provided but agent did not call set_output — structured output is required" ); } // post-agent review cleanup: reportReviewNodeId → follow-up dispatch → delete progress comment. // runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement). // best-effort: cleanup failures must not turn a successful agent run into a failure. if (toolContext) { await postReviewCleanup(toolContext).catch((error) => { log.debug(`post-review cleanup failed: ${error}`); }); } await writeJobSummary(toolState); // emit structured output marker for test validation if (toolState.output) { log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`); core.setOutput("result", toolState.output); } return await handleAgentResult({ result, toolState, silent: payload.event.silent ?? false, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "unknown error occurred"; killTrackedChildren(); log.error(errorMessage); // best-effort summary — don't mask the original error try { await writeJobSummary(toolState); } catch {} try { await reportErrorToComment({ toolState, error: errorMessage }); } catch { // error reporting failed, but don't let it mask the original error } // best-effort review cleanup (e.g., agent timed out after submitting a review) if (toolContext) { await postReviewCleanup(toolContext).catch((error) => { log.debug(`post-review cleanup failed: ${error}`); }); } return { success: false, error: errorMessage, }; } finally { activityTimeout?.stop(); if (usageSummaryPath) { await writeGitHubUsageSummaryToFile(usageSummaryPath); } } }