36ac64a5b6
* fix(oss-codex): prefer user's uploaded Codex auth over OSS subsidy OSS-allowlisted repos with `CODEX_AUTH_JSON` uploaded via `pullfrog auth codex` were still being routed through the OSS OpenRouter subsidy because two paths ignored managed credentials: - `hasProviderKey()` only checked `provider.envVars`, so an `openai/*` model with only `CODEX_AUTH_JSON` present silently fell back to `opencode/big-pickle` via `selectFallbackModelIfNeeded` — the maintainer saw "opencode/big-pickle (resolved from openai/gpt)" on CI even though Codex was configured. - `run-context` set `proxyModel` for every OSS run unconditionally, which the action runtime threads through `payload.proxyModel` and uses to overwrite `OPENROUTER_API_KEY`. Even if `big-pickle` fallback hadn't fired, the runner would consume the $10 OSS subsidy key instead of the user's ChatGPT subscription. Fix: - Add `getModelAuthEnvVars()` covering both `envVars` and `managedCredentials` in `action/utils/apiKeys.ts`; route `hasProviderKey` + `validateAgentApiKey` through it. - `run-context` now skips `proxyModel` for OSS repos when the configured model's provider has matching auth in Pullfrog-stored account/repo secrets, so the runner authenticates directly with the user's Codex subscription (or any other user-provided provider auth). Triggered by mrlubos (`hey-api/openapi-ts`). Companion follow-up tracked for the opaque "(no error message)" classifier swallow that masked the OSS $10 cap exhaustion on PR #3872 runs 25815370844 + 25815443234. * fix(oss): force Kimi K2 for OSS proxy + hide picker in console UI OSS-funded runs were resolving `repo.model` through OpenRouter, so a single Opus / GPT-5.5 run could burn an entire `oss_subsidy` key against the per-key cap and crash mid-stream (e.g. `hey-api/openapi-ts` PR #3872 runs `25815370844` + `25815443234`, ~$9.20 each on a single key). Force `DEFAULT_PROXY_MODEL` (Kimi K2.6 — ~10-50× cheaper) for every OSS proxy mint, regardless of `repo.model`. Per-run spend stays bounded within the cap by structure, not by hope. `repo.model` stays in the DB unchanged — overriding at runtime means leaving the program restores the user's prior pick without a migration. UI: hide the model picker entirely on OSS repos in `AgentSettings`. The field is effectively inert until the repo leaves the program, so exposing it as if it were live was misleading. Replaced with a banner naming Kimi K2 and pointing to `pullfrog auth …` as the opt-out path — that lands the user on the existing #844 bug-2 branch (Pullfrog-stored auth suppresses the OSS proxy entirely; runner uses user credentials + their preferred model). ModelCostsInfo already has its own `isOss` branch for the cost copy, so that section is unchanged. * fix(oss): lowercase comment casing per AGENTS.md * fix(oss): revert banner copy to 'It's on us.' framing per review Maintainer felt 'Kimi K2' as the banner headline lost the warm 'we've got you covered' framing that the existing OSS cost banner uses. Restore 'It's on us.' as the headline, move the model name into the body where it explains the hardcoded choice and points to the opt-out (pullfrog auth codex / account secret). * docs(agents): screenshots must be of the live route, never synthetic Caught myself building a temp `/dev/oss-ui-preview` route with hardcoded JSX copy-pasted from the real component just to grab a screenshot — the result told us nothing about whether the actual integrated UI worked, and the user (rightly) called it out as a waste. Strengthen the rule: screenshots must come from the live route in the running app, driven by the actual component tree and real props. Note the GH OAuth interstitial gotcha so the next agent gets through Clerk → GitHub sign-in on the first try instead of bailing to a fake render. Also bans side-by-side comparison screenshots unless explicitly requested. * fix(oss): one 'It's on us.' banner, not two OSS Agent settings was showing the message twice — once in the Model section, once in the Model costs section right below it. Fold the cost coverage into the model banner ('at no cost to you' + the spend stat) and hide the Model costs subsection entirely for OSS. ModelCostsInfo no longer needs `isOss` / `ossSpendThisMonthUsd` props — call site is gated, so the OSS branch is dead. Removed it and the now-unused props. Non-OSS rendering is unchanged: full Model picker + Model costs subsection with Router / BYOK branches. * feat(action): corepack-aware package manager provisioning before setup customer setup scripts that did `npm i -g pnpm && pnpm install` were installing whatever pnpm "latest" happens to be on the day the run fires, not what the repo declares — and pnpm 11.3 silently writes a new `packageManagerDependencies` block into lockfiles, which the agent's "always push changes" rule then packages into a noisy PR (see #844). resolve the project's pnpm/yarn pin from `package.json` (honoring pnpm 11+ precedence: `devEngines.packageManager` over `packageManager`) and activate it via `corepack prepare ... --activate` BEFORE the setup hook runs. corepack is bundled with node, so this is a no-op on managed infra; failure (no corepack, no network, range-only version) degrades to a warning and the existing PATH binary still runs. also replaces the legacy `npm install -g <pm>@<v>` path in prep with the same helper so behavior is consistent end-to-end. bun/deno still use the legacy installer because corepack doesn't ship shims for them. * chore(console): drop 'npm i -g pnpm' anti-pattern from setup-script placeholder the suggested example trained customers to install pnpm unpinned, which silently picks up whatever's latest at run time. that's exactly the behavior #844 traced lockfile drift back to. now that prep handles package-manager provisioning via corepack from the repo's declared pin, the placeholder is just a frozen-lockfile install — load-bearing only when the repo wants `pnpm install` to actually run (prep already does that), but a much safer default for customers who do paste it in. * refactor(action): introspect opencode models for BYOK detection Replace the static `provider.envVars + provider.managedCredentials` catalog gate in `selectFallbackModelIfNeeded` + `validateAgentApiKey` with two `opencode models` captures around the auth merge: - `captureBaselineModels` BEFORE dbSecrets + Codex auth.json - `captureAuthorizedModels` AFTER both The authorized set is the authoritative source for "can OpenCode route this model" — strictly more accurate than the catalog, which can miss new auth shapes (Codex was one, there will be more). The diff between baseline and authorized is logged as `BYOK auth enabled N model(s)` for operator visibility. Sequencing changes in main.ts: - `createTempDirectory` hoisted out of the try block so `PULLFROG_TEMP_DIR` is set before the early opencode install - `agents.opencode.install()` + baseline capture before dbSecrets - `installCodexAuth()` hoisted up (idempotent — agent re-calls it inside run() and writes the same file) - authorized capture after Codex auth.json materializes - fallback + validateAgentApiKey receive the authorized set as a parameter; tests inject directly with no mocks Deleted: `hasProviderKey`, `getModelAuthEnvVars`, `knownApiKeys` in `action/utils/apiKeys.ts` (only `selectFallbackModelIfNeeded` consumed them, and PR #844's catalog-extension fix is superseded by introspection). `getModelEnvVars` / `getModelManagedCredentials` stay exported for UI and the server-side OSS proxy heuristic in run-context/route.ts. For the claude agent path, validateAgentApiKey keeps the static single-provider check on `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` — `opencode models` is opencode-specific. validateBedrockSetup / validateVertexSetup also stay; they cover region/location/model-id which `opencode models` doesn't catch. When fallback engages, the post-fallback model is the guaranteed-free `opencode/big-pickle`, so validateAgentApiKey is skipped — the fallback gate already authoritatively decided "this model is OK to run". * test(oss): temp add preview-844 to ossRepos for O4 e2e * Revert "test(oss): temp add preview-844 to ossRepos for O4 e2e" This reverts commit 8167e560126b2ac516c32ba1c63c36aa32ae4019. * test(oss): temp add preview-844 to ossRepos for O5 e2e * fix(action): skip validateAgentApiKey when proxyModel is set The new opencode-models BYOK introspection in PR #844 captures the authorized set BEFORE runProxyResolution mints OPENROUTER_API_KEY, so the proxy slug (e.g. `openrouter/moonshotai/kimi-k2.6`) is never in the set. validateAgentApiKey then spuriously threw "no API key found" on every OSS run, even though the proxy key was minted correctly and the inference would have worked. Mirrors the analogous skip in `selectFallbackModelIfNeeded`: when proxyModel is set, the server-side gate (`run-context/route.ts`) is the authority and the proxy mint itself is the validation. Caught by O5 e2e on `pullfrog/preview-844-heyapi-oss-bug`. * Revert "test(oss): temp add preview-844 to ossRepos for O5 e2e" This reverts commit 3bae075ceeb188ee272c45c13b5080e15bcd00a5. * fix(action): discard hook-generated tracked-file drift before agent sees it addresses bug 3 in #844: customer setup/post-checkout hooks like `pnpm install` or `corepack prepare` left the working tree dirty (e.g. `M pnpm-lock.yaml`), the agent took the prompt's "must push" rule literally, opened a spurious bot PR for the lockfile drift, and we ate runs+spend on noise. after each setup / post-checkout hook (opt-in via `normalizeWorkingTreeAfter`), discard tracked-file mods with `git restore --staged --worktree .`. untracked files are preserved — a hook that materializes a `.env` from a template, or emits codegen output, stays visible to the agent. guarded by a pre-hook `git status --porcelain` snapshot: if the tree was already dirty before the hook ran (shouldn't happen — setup runs before any working-tree writes; checkout_pr refuses to run dirty), we warn and skip the discard rather than clobber whatever was there. prepush hook (action/mcp/git.ts) intentionally does NOT opt in — its job is to read the about-to-be-pushed state, not normalize it. * test(oss): temp add preview-844 to ossRepos for bug 3 e2e (revert before merge) * fix(action): skip eager pnpm/npm/etc install when no lockfile exists second half of bug 3 in #844. the eager prep step assumed `pnpm install --frozen-lockfile` (and equivalents) would fail cleanly without a lockfile, leaving the tree untouched. that assumption is false for pnpm 11.1.1 against a no-deps `package.json`: the command reports "Already up to date" with exit 0 AND silently materializes an empty `pnpm-lock.yaml` despite the `--frozen-lockfile` flag. the resulting untracked file trips the post-run dirty-tree gate, the agent reads it as "must push uncommitted work", and a spurious "Add pnpm lockfile" PR lands. smoking gun: pullfrog/preview-844-heyapi-oss-bug PRs #1/#2/#3, all auto-opened by the bot against a repo that contains nothing but a one-line README + a no-deps package.json. guard explicitly with an `existsSync` per manager. if the lockfile is absent, skip eager prep entirely with an info log; the agent can install on demand via the `setup` lifecycle hook (which non-frozen `pnpm install` would handle correctly), or just leave deps uninstalled when the prompt doesn't need them (e.g. the O5 "tell me a joke" path). orthogonal to the lifecycle-hook normalization in 0051bd2a — together they cover the full bug 3 surface: - eager prep can't materialize a lockfile (this commit) - setup/postCheckout hooks that rewrite tracked files have the drift discarded before the agent sees it (prior commit) * fix(action): address Pullfrog review on hook normalization two fixes in `executeLifecycleHook` from review on f6f3b32: 1. pre-hook snapshot was `git status --porcelain` which counts untracked files; in practice any repo with pre-existing untracked content (e.g. `.plans/`, an ignored-but-not-yet-gitignored scratch dir, codegen artifacts) would trip the guard and silently skip normalization, defeating the fix. switch to `git diff --name-only HEAD` so the gate measures the same thing the discard targets — tracked-file mods only. pre-existing untracked files are safe regardless because `git restore --staged --worktree .` never touches them. 2. normalization fired only on the happy path; a hook that updated a lockfile then exploded on a peer-dep conflict left tracked drift for the agent. move the call into a `finally` so it runs on success, non-zero exit, timeout, AND spawn failure. the pre-hook guard still protects pre-existing work in every case. * Revert "test(oss): temp add preview-844 to ossRepos for bug 3 e2e (revert before merge)" This reverts commit f6f3b325d6bf9a1720754ed1d39d248dab76cfa8. * fix(action): use detect lockfile strategy for eager-prep gate addresses Pullfrog review on be3c207b. two findings, one root cause: - the hardcoded LOCKFILE_BY_MANAGER map missed `bun.lockb` and `npm-shrinkwrap.json`, two managers' accepted lockfile variants. - `existsSync(join(cwd, lockfile))` only checked the immediate directory, breaking monorepo subpackages where the lockfile lives at the workspace root. both fall out by replacing the custom check with `detect({ strategies: ["lockfile"] })`. the detector already walks up the tree (subpackage → workspace root) and recognizes every accepted lockfile name across all managers it supports. restricting to the `lockfile` strategy is load- bearing: the default strategy set also matches on `packageManager` / `devEngines.packageManager` package.json fields, which would return non-null and re-mask the very case we're trying to detect (declared manager, no lockfile committed — the O5 / hey-api preview repro). drops the LOCKFILE_BY_MANAGER map entirely; no need for a second detect() call since the existing one was only used for `agent` resolution and that consumer is now after the lockfile gate, where `detected` is guaranteed non-null.
706 lines
30 KiB
TypeScript
706 lines
30 KiB
TypeScript
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
|
|
import { existsSync, readdirSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { agents } from "./agents/index.ts";
|
|
import { reportProgress } from "./mcp/comment.ts";
|
|
import { startInstallation } from "./mcp/dependencies.ts";
|
|
import { startMcpHttpServer, type ToolContext } from "./mcp/server.ts";
|
|
import { computeModes } from "./modes.ts";
|
|
import { initToolState } from "./toolState.ts";
|
|
import {
|
|
type ActivityTimeout,
|
|
createProcessOutputActivityTimeout,
|
|
DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
|
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
|
} from "./utils/activity.ts";
|
|
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
|
import { resolveBody } from "./utils/body.ts";
|
|
import { selectFallbackModelIfNeeded } from "./utils/byokFallback.ts";
|
|
import { log } from "./utils/cli.ts";
|
|
import { installCodexAuth } from "./utils/codexHome.ts";
|
|
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.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 { persistLearnings, seedLearningsFile } from "./utils/learnings.ts";
|
|
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
|
import { normalizeEnv, sanitizeSecret } from "./utils/normalizeEnv.ts";
|
|
import {
|
|
captureAuthorizedModels,
|
|
captureBaselineModels,
|
|
getAuthorizedModels,
|
|
} from "./utils/openCodeModels.ts";
|
|
import { applyOverrides } from "./utils/overrides.ts";
|
|
import { ensurePackageManager, resolvePackageManagerSpec } from "./utils/packageManager.ts";
|
|
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
|
|
import { resolveOutputSchema, resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
|
import { type OidcCredentials, runProxyResolution } from "./utils/proxy.ts";
|
|
import { fetchPreviousSnapshot, persistSummary, seedSummaryFile } from "./utils/prSummary.ts";
|
|
import { handleAgentResult } from "./utils/run.ts";
|
|
import { resolveRunContextData } from "./utils/runContextData.ts";
|
|
import { renderRunError } from "./utils/runErrorRenderer.ts";
|
|
import {
|
|
finalizeSuccessRun,
|
|
persistRunArtifacts,
|
|
writeRunErrorOutputs,
|
|
} from "./utils/runLifecycle.ts";
|
|
import { logRunStartup } from "./utils/runStartupLog.ts";
|
|
import { setEnvAllowlist } from "./utils/secrets.ts";
|
|
import { createTempDirectory, setupGit, wipeRunnerLeakSurface } from "./utils/setup.ts";
|
|
import { killTrackedChildren } from "./utils/subprocess.ts";
|
|
import { resolveTimeoutMs, TIMEOUT_DISABLED } from "./utils/time.ts";
|
|
import { Timer } from "./utils/timer.ts";
|
|
import { createTodoTracker } from "./utils/todoTracking.ts";
|
|
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
|
import {
|
|
cleanupVertexCredentials,
|
|
materializeVertexCredentials,
|
|
type VertexCredentials,
|
|
} from "./utils/vertex.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;
|
|
}
|
|
|
|
export async function main(): Promise<MainResult> {
|
|
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
|
normalizeEnv();
|
|
|
|
// apply caller-supplied env overrides — JSON object forwarded as the
|
|
// UNSAFE_OVERRIDES env var (NOT a `with:` input). gated by `actions:write`
|
|
// on the repo and refuses integrity-critical names; see utils/overrides.ts
|
|
// for the deny-list and wiki/e2e-testing.md for usage + threat model.
|
|
// the `unsafe` prefix is intentional: GH echoes the env-block value in the
|
|
// step-header log, so the raw JSON is visible to anyone with `actions:read`.
|
|
const overridesRaw = process.env.UNSAFE_OVERRIDES ?? "";
|
|
if (overridesRaw.trim()) {
|
|
const result = applyOverrides({ raw: overridesRaw, env: process.env });
|
|
if (result.applied.length > 0) {
|
|
log.info(`» applied ${result.applied.length} env override(s): ${result.applied.join(", ")}`);
|
|
}
|
|
if (result.denied.length > 0) {
|
|
log.warning(
|
|
`» refused to override ${result.denied.length} protected env var(s): ${result.denied.join(", ")}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
let safetyNetTimer: NodeJS.Timeout | undefined;
|
|
|
|
// parse prompt early to extract progressComment for toolState
|
|
const resolvedPromptInput = resolvePromptInput();
|
|
|
|
const toolState = initToolState({
|
|
progressComment:
|
|
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressComment : 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");
|
|
|
|
// tmpdir hoisted out of the try block: `installFromNpmTarball` reads
|
|
// PULLFROG_TEMP_DIR (set as a side effect of createTempDirectory) when
|
|
// the opencode CLI install runs below for BYOK introspection. agent +
|
|
// mcp server setup further down also consume the same tmpdir.
|
|
const tmpdir = createTempDirectory();
|
|
|
|
// install OpenCode + capture the BASELINE model set BEFORE dbSecrets and
|
|
// Codex auth.json are in scope. this is the set of models OpenCode can
|
|
// route from the runner's pre-existing environment alone (workflow
|
|
// `env:` block + GH Actions secrets). install is fs-cached, so the
|
|
// duplicate call inside the opencode agent's run() is a no-op.
|
|
const opencodeCliPath = await agents.opencode.install();
|
|
captureBaselineModels(opencodeCliPath);
|
|
|
|
// inject account-level secrets into process.env (YAML secrets take precedence).
|
|
// sanitizeSecret trims + masks so accidental trailing whitespace doesn't leak
|
|
// through GitHub Actions' line-based log masking. whitespace-only values
|
|
// return null and skip injection so the user sees a clear missing-key error.
|
|
if (runContext.dbSecrets) {
|
|
for (const [key, value] of Object.entries(runContext.dbSecrets)) {
|
|
if (!process.env[key]) {
|
|
const sanitized = sanitizeSecret(key, value);
|
|
if (sanitized !== null) process.env[key] = sanitized;
|
|
}
|
|
}
|
|
const count = Object.keys(runContext.dbSecrets).length;
|
|
if (count > 0) log.info(`» ${count} db secret(s) loaded`);
|
|
}
|
|
|
|
// materialize Codex auth.json (idempotent — opencode agent re-calls inside
|
|
// run() and writes the same file). this has to land BEFORE
|
|
// captureAuthorizedModels so OpenCode's model introspection sees the
|
|
// OAuth-routed openai/* models.
|
|
installCodexAuth();
|
|
|
|
// capture the AUTHORIZED model set after dbSecrets + Codex auth.json are
|
|
// applied. this is the authoritative source for the BYOK fallback
|
|
// decision and the opencode-agent path of validateAgentApiKey — strictly
|
|
// more accurate than the static envVars/managedCredentials catalog,
|
|
// which can miss new auth shapes.
|
|
captureAuthorizedModels(opencodeCliPath);
|
|
|
|
// configure env allowlist for subprocess filtering
|
|
if (runContext.repoSettings.envAllowlist) {
|
|
setEnvAllowlist(runContext.repoSettings.envAllowlist);
|
|
}
|
|
|
|
// resolve payload to determine shell permission
|
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
|
toolState.model = payload.model;
|
|
if (payload.event.trigger === "pull_request_synchronize") {
|
|
toolState.beforeSha = payload.event.before_sha;
|
|
}
|
|
|
|
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
|
await using tokenRef = await resolveTokens({ push: payload.push });
|
|
|
|
// wipe the GHA runner's known credential leak surface inside $RUNNER_TEMP
|
|
// before the agent spawns. our installation token is already in memory
|
|
// (tokenRef above), and setupGit's includeIf strip handles the matching
|
|
// dangling references in the user's .git/config. see wipeRunnerLeakSurface
|
|
// for the leak inventory and threat model.
|
|
wipeRunnerLeakSurface();
|
|
|
|
// 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 managed billing
|
|
// accounts. BillingError (402) and TransientError (503) get rendered inside
|
|
// `runProxyResolution` before being rethrown — handled here (not in the
|
|
// outer catch) because the outer catch needs `toolContext` (not yet built)
|
|
// for its general-purpose error path.
|
|
await runProxyResolution({
|
|
payload,
|
|
oss: runContext.oss,
|
|
proxyModel: runContext.proxyModel,
|
|
oidcCredentials,
|
|
repo: runContext.repo,
|
|
toolState,
|
|
});
|
|
|
|
// create octokit with MCP token for GitHub API calls
|
|
const octokit = createOctokit(tokenRef.mcpToken);
|
|
|
|
const runInfo = await resolveRun({ octokit });
|
|
let toolContext: ToolContext | undefined;
|
|
let progressCallbackDisabled = false;
|
|
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
|
let vertexCredentials: VertexCredentials | 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 ?? "");
|
|
}
|
|
}
|
|
|
|
await using gitAuthServer = await startGitAuthServer(tmpdir);
|
|
setGitAuthServer(gitAuthServer);
|
|
|
|
const initialResolvedModel = payload.proxyModel
|
|
? undefined
|
|
: resolveModel({ slug: payload.model });
|
|
|
|
// BYOK fallback: if the configured model needs a key the runner doesn't
|
|
// have, swap to a free OpenCode model so the run can still produce
|
|
// value. Without this, the agent launches with no key, the LLM provider
|
|
// 401s, and the run dies in seconds with a synthetic "Invalid API key"
|
|
// — exactly the silent-churn pattern that took out 15 accounts before
|
|
// this landed. Router/proxy runs are skipped (Pullfrog mints the key);
|
|
// see `selectFallbackModelIfNeeded` for the full skip set.
|
|
const authorized = getAuthorizedModels();
|
|
const fallback = selectFallbackModelIfNeeded({
|
|
resolvedModel: initialResolvedModel,
|
|
proxyModel: payload.proxyModel,
|
|
authorized,
|
|
});
|
|
// when fallback engages we bypass `resolveModel` for the new slug —
|
|
// `PULLFROG_MODEL` has higher priority than the slug arg inside that
|
|
// helper and would otherwise re-override back to the unkeyed model.
|
|
// the free fallback slug is already a CLI-ready specifier, so using
|
|
// it verbatim is correct and avoids the override.
|
|
const effectiveSlug = fallback.fallback ? fallback.to : payload.model;
|
|
const resolvedModel = fallback.fallback ? fallback.to : initialResolvedModel;
|
|
if (fallback.fallback) {
|
|
log.warning(
|
|
`» fell back from ${fallback.from} to ${fallback.to} — no BYOK key present in runner env. add a provider key in repo secrets to use ${fallback.from} instead.`
|
|
);
|
|
toolState.modelFallback = { from: fallback.from };
|
|
}
|
|
|
|
vertexCredentials = materializeVertexCredentials({ model: resolvedModel });
|
|
|
|
const agent = resolveAgent({ model: resolvedModel });
|
|
|
|
// surface the effective model in comment/review footers. payload.model is
|
|
// just the stored slug (often undefined for router/oss runs that derive
|
|
// the target from proxyModel). matching priority with resolveModelForLog
|
|
// so the "Using `…`" badge reflects what actually ran.
|
|
toolState.model = payload.proxyModel ?? resolvedModel ?? effectiveSlug;
|
|
|
|
// skip validation when fallback engaged: the effective model is the
|
|
// free fallback (`opencode/big-pickle`) and the fallback gate already
|
|
// authoritatively decided "this model is OK to run". re-validating
|
|
// would spuriously throw if `opencode models` doesn't list big-pickle.
|
|
//
|
|
// also skip when proxyModel is set: `runProxyResolution` already minted
|
|
// OPENROUTER_API_KEY and the server-side gate (`run-context/route.ts`)
|
|
// is the authority on "can this run use the router". the `authorized`
|
|
// set was captured BEFORE the proxy mint, so it doesn't see the
|
|
// openrouter slug — re-validating would spuriously throw. mirrors the
|
|
// analogous `if (input.proxyModel) return { fallback: false }` skip in
|
|
// `selectFallbackModelIfNeeded`.
|
|
if (!fallback.fallback && !payload.proxyModel) {
|
|
validateAgentApiKey({
|
|
agent,
|
|
model: payload.proxyModel ?? resolvedModel ?? effectiveSlug,
|
|
authorized,
|
|
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");
|
|
|
|
// pin the project's package manager via corepack BEFORE the setup hook
|
|
// runs. without this, a customer setup script like `npm i -g pnpm &&
|
|
// pnpm install` installs whatever pnpm is "latest" today and writes
|
|
// unrelated lockfile drift (e.g. the `packageManagerDependencies`
|
|
// block pnpm 11.3 added) into our commit — see #844. resolution
|
|
// honors pnpm 11+ precedence (`devEngines.packageManager` over
|
|
// `packageManager`); failure is non-fatal — we fall back to whatever
|
|
// is on PATH with a warning.
|
|
const pmSpec = await resolvePackageManagerSpec(process.cwd());
|
|
if (pmSpec) {
|
|
await ensurePackageManager(pmSpec);
|
|
}
|
|
timer.checkpoint("packageManager");
|
|
|
|
// execute setup lifecycle hook (runs once at initialization).
|
|
// setup is load-bearing — if it fails the rest of the run is in an
|
|
// undefined state, so upgrade the soft-fail warning to a hard error.
|
|
const setupHook = await executeLifecycleHook({
|
|
event: "setup",
|
|
script: runContext.repoSettings.setupScript,
|
|
normalizeWorkingTreeAfter: true,
|
|
});
|
|
if (setupHook.warning) {
|
|
throw new Error(setupHook.warning);
|
|
}
|
|
timer.checkpoint("lifecycleHooks::setup");
|
|
|
|
const agentId = agent.name;
|
|
const modes = [...computeModes(agentId), ...runContext.repoSettings.modes];
|
|
|
|
const outputSchema = resolveOutputSchema();
|
|
|
|
// mcpServerUrl and tmpdir are set after server starts
|
|
toolContext = {
|
|
agentId,
|
|
repo: runContext.repo,
|
|
payload,
|
|
octokit,
|
|
githubInstallationToken: tokenRef.mcpToken,
|
|
gitToken: tokenRef.gitToken,
|
|
apiToken: runContext.apiToken,
|
|
modes,
|
|
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
|
prepushScript: runContext.repoSettings.prepushScript,
|
|
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
|
|
modeInstructions: runContext.repoSettings.modeInstructions,
|
|
toolState,
|
|
runId: runInfo.runId,
|
|
jobId: runInfo.jobId,
|
|
mcpServerUrl: "",
|
|
tmpdir,
|
|
oss: runContext.oss,
|
|
plan: runContext.plan,
|
|
resolvedModel,
|
|
};
|
|
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
|
toolContext.mcpServerUrl = mcpHttpServer.url;
|
|
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
|
timer.checkpoint("mcpServer");
|
|
|
|
// seed the rolling repo-level learnings tmpfile for every run. the
|
|
// agent reads the file at startup (path is surfaced in the LEARNINGS
|
|
// section of the prompt) and may edit it during the post-run
|
|
// reflection turn. persistLearnings reads it back at end-of-run and
|
|
// PATCHes any changes to Repo.learnings, byte-trim equality against
|
|
// the seed gates the API call. always-seed (vs gated): learnings are
|
|
// universal — any run can produce them, and gating just hides the
|
|
// affordance.
|
|
//
|
|
// wrapped in best-effort try/catch: this block runs unconditionally,
|
|
// and an unwrapped filesystem failure (ENOSPC, EACCES, hostile sandbox)
|
|
// would unwind into the outer main() catch and flip an otherwise-
|
|
// successful run to "❌ Pullfrog failed" before the agent even starts.
|
|
// on failure toolState.learningsFilePath stays unset, and downstream
|
|
// consumers (`persistLearnings`, agent harnesses, `resolveInstructions`)
|
|
// all treat undefined as "no learnings affordance this run".
|
|
try {
|
|
const learningsPath = await seedLearningsFile({
|
|
tmpdir,
|
|
current: runContext.repoSettings.learnings,
|
|
});
|
|
toolState.learningsFilePath = learningsPath;
|
|
// file on disk is the verbatim DB body, so the seed used for
|
|
// change-detection is just `current ?? ""` (trimmed). persistLearnings
|
|
// byte-compares against the trimmed read-back to skip no-op PATCHes.
|
|
toolState.learningsSeed = (runContext.repoSettings.learnings ?? "").trim();
|
|
log.info(
|
|
`» learnings seeded at ${learningsPath} (existing=${runContext.repoSettings.learnings ? "yes" : "no"})`
|
|
);
|
|
const ctxForExit = toolContext;
|
|
onExitSignal(() => persistLearnings(ctxForExit));
|
|
} catch (err) {
|
|
log.warning(
|
|
`» learnings seed failed: ${err instanceof Error ? err.message : String(err)} — continuing without learnings file`
|
|
);
|
|
}
|
|
|
|
// seed the rolling PR summary tmpfile when the dispatcher requested it.
|
|
// gated on event being a PR — issue/workflow_dispatch runs have no
|
|
// summarySnapshot to maintain. file path is exposed to the agent via
|
|
// the select_mode response addendum (action/mcp/selectMode.ts).
|
|
if (payload.generateSummary && payload.event.is_pr && payload.event.issue_number) {
|
|
const previousSnapshot = await fetchPreviousSnapshot(toolContext, payload.event.issue_number);
|
|
const filePath = await seedSummaryFile({ tmpdir, previousSnapshot });
|
|
toolState.summaryFilePath = filePath;
|
|
// capture the exact bytes the agent will see at startup. used by
|
|
// the post-run retry loop to detect the agent forgetting to edit
|
|
// the file (byte-identical to seed → nudge once via resume turn)
|
|
// and by persistSummary to skip the DB write when nothing changed.
|
|
try {
|
|
toolState.summarySeed = await readFile(filePath, "utf8");
|
|
} catch {
|
|
// intentionally empty — summarySeed stays undefined
|
|
}
|
|
log.info(
|
|
`» summary snapshot seeded at ${filePath} (previous=${previousSnapshot ? "yes" : "no"})`
|
|
);
|
|
// on SIGINT/SIGTERM we still want to persist whatever the agent has
|
|
// written so far. handler is best-effort: any failure inside is
|
|
// swallowed by Promise.allSettled in exitHandler.ts, and the
|
|
// summaryPersistAttempted guard prevents double-execution if the
|
|
// signal arrives after the normal path already persisted. capture a
|
|
// narrowed reference so the closure doesn't depend on the outer
|
|
// `toolContext` variable being defined later.
|
|
const ctxForExit = toolContext;
|
|
onExitSignal(() => persistSummary(ctxForExit));
|
|
}
|
|
|
|
startInstallation(toolContext);
|
|
|
|
logRunStartup({ payload, resolvedModel, agentName: agent.name });
|
|
|
|
const instructions = resolveInstructions({
|
|
payload,
|
|
repo: runContext.repo,
|
|
modes,
|
|
agentId,
|
|
outputSchema,
|
|
learningsFilePath: toolState.learningsFilePath ?? null,
|
|
learningsHeadings: runContext.repoSettings.learningsHeadings,
|
|
});
|
|
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",
|
|
});
|
|
log.group("View full prompt", () => {
|
|
log.info(instructions.full);
|
|
});
|
|
|
|
// OpenCode loads .opencode/plugin/ files at startup. if the repo has any,
|
|
// eagerly await dependency installation so plugin imports can resolve.
|
|
if (agentId === "opencode") {
|
|
const pluginDir = join(process.cwd(), ".opencode", "plugin");
|
|
const hasPlugins =
|
|
existsSync(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
|
|
if (hasPlugins && toolState.dependencyInstallation?.promise) {
|
|
log.info(
|
|
"» .opencode/plugin/ detected — awaiting dependency installation before agent start"
|
|
);
|
|
await toolState.dependencyInstallation.promise.catch(() => {});
|
|
timer.checkpoint("awaitDepsForPlugins");
|
|
}
|
|
}
|
|
|
|
// 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
|
|
todoTracker = createTodoTracker(async (body) => {
|
|
if (progressCallbackDisabled || !toolContext) return;
|
|
try {
|
|
await reportProgress(toolContext, { body });
|
|
} catch (err) {
|
|
log.debug(`progress update failed: ${err}`);
|
|
}
|
|
});
|
|
toolState.todoTracker = todoTracker;
|
|
|
|
// on cancellation, stop scheduling new tracker writes immediately. without this, a
|
|
// debounced write queued just before SIGTERM could land at GitHub *after* the
|
|
// workflow_run.completed webhook has already replaced the comment with the
|
|
// "This run was cancelled" body, clobbering it back to the task list. we can't
|
|
// await in-flight writes (the process is exiting), but cancelling the timer
|
|
// shrinks the race window.
|
|
onExitSignal(() => {
|
|
todoTracker?.cancel();
|
|
});
|
|
|
|
// when the agent subprocess is killed for inner activity timeout, stop
|
|
// the MCP HTTP server so mcp-proxy's SSE reconnect attempts don't keep
|
|
// the outer activity timer alive. start a short safety-net timer — if
|
|
// the agent promise hasn't resolved within 5min after the inner kill,
|
|
// force-reject the outer timer so the run can exit.
|
|
let innerTimeoutFired = false;
|
|
const onInnerActivityTimeout = () => {
|
|
if (innerTimeoutFired) return;
|
|
innerTimeoutFired = true;
|
|
log.info(
|
|
"» inner activity timeout fired — stopping MCP server and starting 5min safety-net timer"
|
|
);
|
|
// fire and forget — the server's dispose is idempotent so the
|
|
// `await using` cleanup at block exit is still safe.
|
|
mcpHttpServer[Symbol.asyncDispose]().catch((err) => {
|
|
log.debug(
|
|
`mcp server stop after inner kill failed: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
});
|
|
safetyNetTimer = setTimeout(
|
|
() => {
|
|
activityTimeout?.forceReject(
|
|
"agent still pending 5min after inner activity kill — forcing exit"
|
|
);
|
|
},
|
|
5 * 60 * 1000
|
|
);
|
|
safetyNetTimer.unref?.();
|
|
};
|
|
|
|
const agentPromise = agent.run({
|
|
payload,
|
|
resolvedModel,
|
|
mcpServerUrl: mcpHttpServer.url,
|
|
tmpdir,
|
|
secretDenyPaths: vertexCredentials ? [vertexCredentials.secretDir] : [],
|
|
instructions,
|
|
todoTracker,
|
|
stopScript: runContext.repoSettings.stopScript,
|
|
toolState,
|
|
apiToken: runContext.apiToken,
|
|
onActivityTimeout: onInnerActivityTimeout,
|
|
onToolUse: (event) => {
|
|
const wasTracked = recordDiffReadFromToolUse({
|
|
state: toolState.diffCoverage,
|
|
toolName: event.toolName,
|
|
input: event.input,
|
|
cwd: process.cwd(),
|
|
});
|
|
if (!wasTracked) return;
|
|
const trackedRanges = toolState.diffCoverage?.coveredRanges ?? [];
|
|
log.debug(
|
|
`» diff coverage tracked from tool ${event.toolName} (${trackedRanges.length} merged range${trackedRanges.length === 1 ? "" : "s"})`
|
|
);
|
|
},
|
|
});
|
|
// symmetric with the activityTimeout/timeoutPromise catches below: if a
|
|
// timeout wins the race, agentPromise is stranded and its later rejection
|
|
// becomes an unhandled rejection. node 15+ terminates the process on
|
|
// unhandled rejection by default, which would kill main() mid-cleanup and
|
|
// lose the error-reporting / usage-summary work that follows. the race
|
|
// still sees the rejection (the original promise is shared); this catch
|
|
// only keeps node from treating a post-race rejection as unobserved.
|
|
agentPromise.catch(() => {});
|
|
|
|
// 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<typeof agentPromise>;
|
|
if (payload.timeout === TIMEOUT_DISABLED) {
|
|
result = await Promise.race([agentPromise, activityTimeout.promise]);
|
|
} else {
|
|
// resolveTimeoutMs rejects unparseable / zero / setTimeout-overflow inputs
|
|
// so a bad string can't silently resolve to an instant timeout. fall back
|
|
// to the 1h default with a warning — users who want runtime measured in
|
|
// weeks should use --notimeout.
|
|
const usable = resolveTimeoutMs(payload.timeout);
|
|
if (payload.timeout && usable === null) {
|
|
log.warning(`invalid timeout "${payload.timeout}" (use --notimeout to disable), using 1h`);
|
|
}
|
|
const timeoutMs = usable ?? 3600000;
|
|
const actualTimeout = usable !== null ? payload.timeout : "1h";
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
const timeoutPromise = new Promise<never>((_, 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"
|
|
);
|
|
}
|
|
|
|
// success-path cleanup: postReview → persistSummary → persistLearnings →
|
|
// failure-error-report → stranded-comment cleanup → job summary → output
|
|
// marker. each step is best-effort; see `finalizeSuccessRun` for ordering
|
|
// rationale (notably: progress-comment deletion lives in
|
|
// create_pull_request_review for review-mode runs, so deletion here
|
|
// covers the non-review success paths).
|
|
await finalizeSuccessRun({ toolContext, toolState, result, repo: runContext.repo });
|
|
|
|
return await handleAgentResult({
|
|
result,
|
|
toolState,
|
|
silent: payload.event.silent ?? false,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
|
|
progressCallbackDisabled = true;
|
|
todoTracker?.cancel();
|
|
killTrackedChildren();
|
|
log.error(errorMessage);
|
|
|
|
// classify (BillingError reclassification + hang detection + API-key auth
|
|
// detection) and render to {summary, comment} markdown bodies.
|
|
const rendered = renderRunError({
|
|
errorMessage,
|
|
repo: runContext.repo,
|
|
agentDiagnostic: toolState.agentDiagnostic,
|
|
});
|
|
await writeRunErrorOutputs({ rendered, toolState });
|
|
|
|
// best-effort cleanup: review dispatch, summary persist, learnings persist.
|
|
// a partial edit before the crash is still worth keeping.
|
|
if (toolContext) {
|
|
await persistRunArtifacts(toolContext);
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
activityTimeout?.stop();
|
|
if (safetyNetTimer) clearTimeout(safetyNetTimer);
|
|
if (usageSummaryPath) {
|
|
// a write error here (ENOSPC, EACCES, dirname removed) must not mask
|
|
// either the try's successful return or the catch's error return.
|
|
// the summary is informational — log and move on.
|
|
try {
|
|
await writeGitHubUsageSummaryToFile(usageSummaryPath);
|
|
} catch (err) {
|
|
log.debug(
|
|
`failed to write usage summary to ${usageSummaryPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// persist aggregated token + cost usage to the WorkflowRun row.
|
|
// this is the single shared cleanup path across every agent implementation:
|
|
// each agent harness returns a single AgentUsage from agent.run() that
|
|
// already aggregates its internal retries via mergeAgentUsage, and the
|
|
// success branch above pushes that entry into toolState.usageEntries.
|
|
// aggregateUsage sums across those entries (one per agent.run()).
|
|
//
|
|
// caveat: if the agent promise rejected (timeout or uncaught throw) the
|
|
// usage was never pushed, so nothing gets persisted for that run. runs
|
|
// that returned AgentResult with success=false still report their partial
|
|
// usage because the harness populates AgentUsage before returning.
|
|
if (toolContext) {
|
|
const patch = aggregateUsage(toolState.usageEntries);
|
|
if (Object.keys(patch).length > 0) {
|
|
await patchWorkflowRunFields(toolContext, patch);
|
|
}
|
|
}
|
|
cleanupVertexCredentials(vertexCredentials);
|
|
}
|
|
}
|