* 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.
reflection turn is a meta-turn for editing the learnings file; the
task's `result` output was already finalized on the previous turn.
gemini pro re-triggers on the standing "call set_output when done"
system instruction during reflection and clobbers the value with the
literal word "done" (see ci run 26529624199, smoke test on
providers-live google/gemini-pro). add an explicit prohibition to the
reflection prompt; the snapshot/restore in runPostRunRetryLoop
remains as defense in depth.
models occasionally call `pullfrog_git({command:"status", args:["status"]})`,
which shells out to `git status status`. git silently treats args[0] as a
pathspec — when no file/dir matches, status prints "nothing to commit,
working tree clean" even on a dirty tree. observed in production
(Skn0tt/beckerbuch run 26519563044): the agent looped trying to reconcile
that against a real diff, burned ~$3 / ~6min of opus, and only escaped when
it switched to `args: ["--porcelain"]`. generalises to every subcommand
(`diff diff`, `log log`, ...).
guard `args[0]?.toLowerCase() === command.toLowerCase()` with a directed
throw pointing the model at the disambiguated `args: ["--", "<name>"]`
escape hatch for the rare legitimate pathspec case (`--` works under every
subcommand, unlike a bare positional which can be parsed as a ref by
log/diff/checkout/restore/reset).
description also leads with the no-args case and explicitly forbids
repeating the subcommand in args. schema already had args.optional().
also includes in-flight working-tree work:
- postRun: snapshot/restore toolState.output across reflection turn so reflection prompt can't clobber task-turn output (gemini pro regression)
- toolState: widen `output` to `string | undefined` for assignability
- uninstallFeedback: suspend-mode emails now CTA the GitHub unsuspend page when accountType is known; delete events keep console pointer
* fix(askpass): scope code + script lifetime to one $git() call, not first password prompt
LFS pre-push (and any auth-bound sibling subprocess) consumed the single-use
code AND triggered the script self-delete, so git's own push call then hit
`fatal: cannot exec '/tmp/pullfrog-…/askpass-…js'` and our server treated
the legitimate retry as tamper, revoking the installation token. Observed
on nteract/nteract#2987 (LFS repo).
`gitAuthServer` codes are now `active` until `$git()`'s finally calls
`revoke()`; the script no longer self-deletes (finally already unlinks).
Replay after revoke still trips 409 + token revocation, which is the
realistic exfiltration vector we care about.
* fix(askpass): drop wall-clock TTL on active codes
Copilot review on #841 noticed the 5-minute CODE_TTL_MS still applied to
active codes, which would re-introduce the original LFS failure mode at a
different boundary: a large LFS push lasting >5min would hit a 404 mid-
call. $git() uses `activityTimeout: 0` precisely because git fetch/push
can take arbitrarily long, so any wall-clock TTL on active codes is wrong.
Active codes now live until revoke() is called (in $git()'s finally) or
the auth server is closed. Revoked codes keep their 60s replay trap.
* docs(askpass): purge stale single-use vocabulary; align error message
Pullfrog review on #841 surfaced four doc-drift sites that still described
the pre-PR single-use model:
- wiki/security.md — 3 references (overview prose, bullets, threat-mitigation table)
- action/utils/gitAuth.ts — file-level JSDoc + the 409 error message
- wiki/askpass.md — error message quoted in the tamper-evident section
- action/utils/gitAuthServer.ts — per-prompt invocation comment was ambiguous
All updated to match the active|revoked vocabulary; error message is now
"askpass code was replayed after revoke, token revoked".
* fix: 6 unaddressed log-audit / run-audit findings
- #836 + #818 (clerk middleware SyntaxError on action-runtime endpoints):
narrow proxy.ts matcher to exclude /api/repo/<owner>/<repo>/run-context,
/api/runtime/*, /api/proxy-token. these are server-to-server with their
own auth and have no Clerk session to evaluate, so clerkMiddleware's
decodeJwt throws turn into 500s on every request.
- #837 (npx EBADDEVENGINES on customer's package.json): change runCli's
bootstrap cwd from $GITHUB_WORKSPACE to os.tmpdir() so npm v11+ doesn't
enforce devEngines.packageManager from the customer's tree before our
bootstrap can install pullfrog. CLI process.chdir's to payload.cwd
internally, so the runtime work still happens in $GITHUB_WORKSPACE.
- #838 (createWorkflowDispatch silently dropped 39 user runs on a 5xx
spike): add bounded retry (3 attempts, ~750ms total) on Octokit 5xx and
network errors inside dispatchReservedRun. preserves the existing 422
"Unexpected inputs" path. retry budget stays well under GitHub's 10s
webhook redelivery window.
- #833 (bail() redirect("/signout") propagated NEXT_REDIRECT into webhook
handlers, 40+ 500s/24h): drop the redirect side-effect; bail now just
classifies bad-credentials as non-retryable and propagates. UI flows
that wanted auto-signout on revoked tokens can detect it themselves;
the side-effect was wrong for any non-page caller.
- #835 (BYOK provider billing-exhausted fell through to raw error
renderer, 37 review-mode runs/24h with no PR-side signal):
- extend providerErrors patterns with Anthropic "credit balance is too
low" + extract isProviderBillingExhausted / extractProviderId helpers
- add a renderer branch in runErrorRenderer.ts that names the provider
(parsed from providerID=) and links to its billing dashboard
- route handleAgentResult's !result.success path through the renderer
+ reportErrorToComment with createIfMissing, so review-mode and
silent triggers get an actionable PR comment regardless of mode
- #834 (post-hook ERR_MODULE_NOT_FOUND already fixed on main, hardening):
- add a vitest invariant that walks the entryPost.ts import graph and
refuses any non-relative / non-node: specifier — catches the next
`@actions/core` slip-up before publish
- add an analyze-logs classifier so future entryPost crashes surface as
failure:post-hook-module-not-found instead of hiding inside
failure:unknown / failure:git-lock-file
Co-authored-by: Cursor <cursoragent@cursor.com>
* anneal: fix dead-code matcher, 404 url, silent-trigger gate, and grammar regression
Round-1 anneal pass on the audit-fixes PR surfaced two critical issues +
several majors that the original fixes shipped with:
- proxy.ts matcher 1 (`(?!_next|[^?]*\.(...))`) still caught every
/api/... route because matcher arrays are OR'd. The narrowing in
matcher 2 was dead code → middleware still 500'd on /api/runtime/*,
/api/proxy-token, /api/repo/.../run-context. Carve-out now lives in
BOTH matchers.
- opencode.ai/billing returns 404; canonical top-up surface is /zen.
deepseek /usage is the consumption page, not the top-up flow (/top_up
is correct). google /apikey is the keys list, not billing (/usage is
the spend dashboard). All three URL strings updated.
- handleAgentResult gated reportErrorToComment behind `if (!ctx.silent)`,
contradicting the createIfMissing intent — silent IncrementalReview /
pull_request_synchronize / auto-label still got zero PR signal on BYOK
billing exhaustion, the exact failure mode #835 was meant to fix.
Moved createIfMissing into finalizeSuccessRun's existing render-and-
post block (single source of truth), reverted handleAgentResult to its
prior shape. Side benefit: drops the double-PATCH that fired on every
non-silent !success path with an existing progress comment.
- Anthropic-direct error rendered "**Your your provider account is out
of credit.**" because Anthropic SDK has no providerID= tag, so
extractProviderId returned null and the headline composed
"Your " + "your provider". Added detectProviderId Anthropic fallback
(matches "Anthropic API" / "credit balance is too low") so the link
is reachable AND made the headline conditional on whether a provider
id was detected.
- Reordered renderRunError classifier: BYOK billing-exhausted now runs
BEFORE api-key auth detection. Providers commonly return 401 for
billing exhaustion (DeepSeek, Gemini), and the OpenCode harness logs
often include "API Error: 401" in the raw error body, which
isApiKeyAuthError would otherwise match — surfacing "rotate your key"
when the actual fix is "top up credits".
- isTransientUpstreamError missed ENOTFOUND / ENETUNREACH / EHOSTUNREACH
(undici DNS-class failures Octokit doesn't wrap with a status). Added
to the prefix alternation.
- Tightened "10s webhook redelivery budget" / "GitHub redelivers"
wording in triggerWorkflow.ts and bail.ts JSDoc — GitHub's 10s is the
response timeout (it doesn't auto-redeliver); upstream webhook proxy
retries are what multiplied the failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* anneal r2: unbreak proxy.ts matcher 2, extend carve-out, mkdtemp bootstrap
Round-2 anneal (security + cross-cutting lenses) on top of the round-1
audit-fixes commit caught a critical regression + several majors:
- proxy.ts matcher 2 from r1 (`/(api|trpc)(?!...)(.*)`) does NOT compile.
path-to-regexp rejects a top-level `(?!` after `)` as "Pattern cannot
start with '?'", and Next.js's SourceSchema runs the same validator at
build time and aborts via `process.exit(1)`. PR #840's Vercel + preview
deployments have been failing since 978eca26 for exactly this reason.
Fixed by nesting the lookahead inside an outer parameter group:
`/(api|trpc)((?!...).*)`. Same shape matcher 1 already uses, which is
why m1 always compiled. Verified `pnpm next build` succeeds end-to-end.
- proxy.ts carve-out was incomplete relative to its own justification.
The "no Clerk session, decodeJwt 500" failure mode applies to ALL
server-to-server action-runtime endpoints — five more share the exact
shape: /api/repo/.../learnings, /api/repo/.../pr/.../summary-comment,
/api/repo/.../issue/.../plan-comment, /api/workflow-run/, and
/api/github/installation-token. Extended both matchers. /api/upload/
signed-url stays in (dual auth: Clerk session OR bearer JWT — needs
middleware for the user path).
- proxy.ts carve-outs were unanchored: a future /api/proxy-token-info,
/api/proxy-tokens, or /api/repo/X/Y/run-context-foo would silently
bypass Clerk. Added `(?:$|/)` for path-prefix carve-outs (allow exact
match or sub-path), `$` for routes with browser-callable siblings
(e.g. `learnings/history` is browser-Clerk, `learnings$` is action-
bearer-JWT). Verified 30/31 routes via path-to-regexp test harness.
- runCli.ts cwd flipped from $GITHUB_WORKSPACE to os.tmpdir() in r1
(#837 fix for npm v11 devEngines.packageManager EBADDEVENGINES). But
$TMPDIR is overridable from a prior $GITHUB_ENV step — a customer-
authored or compromised prior step can plant /atk/node_modules/
pullfrog/ and `echo "TMPDIR=/atk" >> $GITHUB_ENV`, and our npx
--yes pullfrog@<v> bootstrap resolves the local install first,
executing attacker code with full action env (provider keys, OIDC,
installation token, CODEX_AUTH_JSON). Switched to mkdtempSync(join
(tmpdir(), "pullfrog-bootstrap-")) — fresh per-invocation 0700 dir,
not pre-writable by anything earlier in the job.
- runLifecycle.ts: writeRunErrorOutputs (catch-path) didn't pass
createIfMissing: true, contradicting the symmetric intent of the
r1 finalizeSuccessRun fix. Silent triggers (IncrementalReview /
pull_request_synchronize / auto-label) that throw past the success
path still got zero PR signal — exact failure mode #835 was meant
to close. Now both paths pass createIfMissing: true.
- runErrorRenderer.ts detectProviderId regex `/Anthropic API|credit
balance is too low/i` could mis-tag a non-Anthropic billing-exhausted
error that mentioned "Anthropic API" in passing (fallback-chain agent
prompt text, OpenCode harness logs). Tightened to /credit balance is
too low/i — Anthropic-specific phrasing, sufficient for the direct-
Anthropic SDK case the fallback exists to handle.
- runErrorRenderer.ts JSDoc: r1's classifier reorder put hang at #6 in
code but the JSDoc still listed it at #2. Reordered the doc to match
dispatch order, with explicit note that hang is a sub-source for the
api-key check (which is why hangBody is precomputed early).
- analyze-logs.ts: ERR_MODULE_NOT_FOUND.*entryPost regex needs `s`
flag so it survives Node v23+ stack-trace reformatting onto multiple
lines. One-char fix to defend the #834 classification bucket.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
- utils/ossRepos.ts: add middleapi/orpc (orpc.dev, 5.2k stars) to the
oss allowlist so any future install mints via mintOssKey ($10 cap,
pullfrog absorbs).
- scripts/skill.ts + pnpm skill: scaffold .agents/skills/<name>/SKILL.md
+ .claude/skills/<name> symlink. patch + skill skills written using it.
- AGENTS.md: hard-ban vi.* mocking apis; document pnpm skill workflow.
- audit follow-through: drop pure-mock test files (action/utils/lifecycle,
action/utils/timer, test/handleNoInstall) and trim action/mcp/review to
the non-mock cases.
- wiki/scripts.md: row for scripts/skill.ts.
c45a07e5 swapped the opencode harness default to moonshotai/kimi-k2,
which broke push-enabled (Kimi reported delete_branch as "auth failed"
where sonnet handles it). The agnostic test matrix expects Sonnet-grade
tool-calling; switching to Kimi was scope creep on top of the original
"don't default to opus" ask — opencode's default was already sonnet, not
opus, so nothing needed to change there.
* expose push: restricted to users
the action runtime already understood `disabled | restricted | enabled`
for `push` (see action/external.ts, action/mcp/git.ts); the DB enum, the
zod schema, the React settings UI, and the action.yml input docs lagged
behind. catches all four up. UI surfaces a 2-state toggle (restricted
↔ enabled) in the Security card alongside shell isolation — `disabled`
stays in the enum so non-UI callers (action.yml input) still accept it
but is intentionally hidden from the console.
* address reviews: doc full restricted scope + disabled UI edge case
reviewers flagged that "restricted" blocks more than default-branch
pushes (also delete_branch + push_tags per action/mcp/git.ts:621-700),
and that the popover's "off = full push access" claim is wrong for
repos whose workflow sets `push: disabled` (also renders as OFF since
the toggle is `props.push === "restricted"`). action.yml description
now enumerates the full restricted scope, and the popover frames off
behavior in terms of the workflow input so disabled repos aren't
misrepresented.
- opencode test default: claude-sonnet-4-6 → moonshotai/kimi-k2. claude-code
default stays on sonnet (the agent-under-test for that path is Claude
Code); the opencode path doesn't need an Anthropic model.
- byok-no-keys-fallback fixture: anthropic/claude-opus → moonshotai/kimi-k2.
test verifies fallback to opencode/big-pickle, so the configured model
is never invoked; picking a non-opus alias avoids burning credits if
the fallback ever regresses.
- BEDROCK_MODEL_ID in both test workflows: us.anthropic.claude-opus-4-6-v1
→ us.anthropic.claude-sonnet-4-6. env var is required by the ci.test
invariant that every provider's env vars are wired into the workflow.
* adhoc: push:restricted adversarial pentest
enumerates the 16 attack vectors the deep audit identified as load-bearing
for `push: restricted`. used to drive e2e verification against the preview
repo's pullfrog.yml; also runnable via pnpm runtest locally.
validator only asserts that the repo's default branch SHA didn't move —
the per-attack outputs are the deliverable for human review (the test
exists to feed adversarial runs, not to be a CI guard).
* wipe runner leak surface before agent spawn
the GHA runner persists credentials inside $RUNNER_TEMP that an MCP-shell
agent can grep — _runner_file_commands/set_output_* (from any composite
step that called core.setOutput, e.g. pullfrog/pullfrog/get-installation-token
which leaks a ghs_… installation token), <uuid>.sh rendered step scripts
(whose run: | body embeds ${{ ... }} expressions literally before write),
and git-credentials-*.config from actions/checkout@v6.
snapshot-and-delete that surface at action startup, after our own token is
in memory and before setupGit. preserves $GITHUB_OUTPUT, $GITHUB_ENV, and
$GITHUB_STATE so pullfrog's result output and post: hook still work.
setupGit's existing removeIncludeIfEntries call strips the matching
dangling includeIf.gitdir:....path entries from the user's .git/config.
does not tighten isGitCommand — that's a UX guard, not a security
boundary, and trivially bypassable via bash -c, absolute paths, symlinks,
python subprocess. the security boundary is the absence of credentials on
disk for those bypassed shells to authenticate with.
verified end-to-end by re-firing action/test/adhoc/pushRestrictedAdversarial
against pullfrog/preview-827-push-restricted-pentest.
* preserve all runner file-command paths from wipe
addresses pullfrog review on f7f5143b: GITHUB_STEP_SUMMARY also lives at
$RUNNER_TEMP/_runner_file_commands/step_summary_<uuid> and is read by the
runner AFTER our step exits to render the job summary in the GH UI. wiping
it silently broke pullfrog's job summary output. preserve GITHUB_PATH too
for symmetry — it's the same allocation pattern, and a step or post hook
that appends a directory expects the file to exist.
set of file-command env vars enumerated in @actions/core:
GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY
the skills CLI rejects "claude" — its valid list is claude-code,
opencode, cursor, etc. caused agent-browser skill install to fail
on every claude run.
* surface agent failures in job summary (#632)
when the agent harness returns `{success: false, error}`, main.ts went
through the success path so the catch block — which renders the
`### ❌ Pullfrog failed` banner via renderRunError — never fired. result:
the GitHub Actions job summary showed only the partial body + usage
table, no error block. the progress comment had a narrow workaround
that re-implemented the api-key classifier inline.
unify: in finalizeSuccessRun, call renderRunError once when `!success`,
use `.summary` for the job summary (prepended to the existing
body/usage parts) and `.comment` for the progress comment. removes the
duplicated isApiKeyAuthError / formatApiKeyErrorSummary branch and
picks up BillingError reclassification + hang body for free.
* docs: note dual-surface failure rendering in finalizeSuccessRun
* fix copilot review nit: clarify which renderRunError classifications carry the H3 banner
* checkout_pr: refuse unconditionally on dirty working tree
drop the live-HEAD comparison from the guard introduced in #796. any
checkout_pr call with staged or unstaged changes now throws, even when
HEAD is already on pr-N. no stashing, no idempotent escape hatch.
motivation is the zed-industries/cloud (2026-05-18) incident: shared-cwd
subagents make "carry edits along" semantics dangerous, and the
HEAD-equality predicate let a re-checkout silently inherit working-tree
state from a sibling agent. forcing commit/discard before any
PR-context operation eliminates the entire carry-forward failure class.
error names the PR number, lists dirty paths, and tells the agent to
commit/push/restore/clean before retrying.
* improve dirty-tree error: precise discard commands
copilot caught two sloppy bits in the error string:
- "push" alone does not clean a dirty tree (needs commit first)
- bare `git clean` is a no-op without `-fd`
reword to "commit (then push if needed), or discard with
`git restore --staged --worktree .` / `git clean -fd`" so the
guidance is actually actionable.
* checkout_pr: initial-branch invariant
setupGit captures `toolState.initialBranch` at run start via live
`git rev-parse --abbrev-ref HEAD`. checkout_pr refuses unless current
HEAD matches the run-entry branch or the target `pr-N` (idempotent
same-PR re-checkout). uses live rev-parse, not toolState.issueNumber
(poisonable per the PR #796 review).
refusal error names the current branch, target PR, recovery path
(`git checkout <initialBranch>` with the literal branch name), and
explicitly states routing around via the `git` tool is not sanctioned.
closes the zed-industries/cloud (2026-05-18) shape where a subagent
parked HEAD on someone else's `pr-X` and the orchestrator's next
checkout_pr inherited that position.
* reviewfrog: enforce canonical diff + pre-commit halt; align Build dispatch
extend REVIEWER_SYSTEM_PROMPT with two prepended HARD CONSTRAINTS:
- first action MUST be `git diff origin/<base>` (single-rev, captures
uncommitted). no other diff first; no checkout_pr; no alt-ref fetches;
no branch listing; no `gh pr list`.
- empty canonical diff + claimed-changes dispatch ⇒ reply exactly with
`no changes detected — likely pre-commit Build self-review;
orchestrator should commit then re-dispatch` and stop. do not guess
PR numbers (the zed thrash that ended in `checkout_pr({2582})`).
reshape Build mode reviewfrog dispatch step around a verbatim template
that names: (a) the situation is pre-commit, (b) canonical diff command,
(c) halt-on-empty-diff rule. orchestrator side now says the same thing
as the reviewer's baked-in prompt. delegation-discipline bullets and
orchestrator-evaluation guidance kept intact.
* checkout_pr: handle detached-HEAD entry in initial-branch invariant
pullfrog incremental review caught a defense-in-depth gap: `git
rev-parse --abbrev-ref HEAD` returns the sentinel string `"HEAD"` on
detached entry, which is the default `actions/checkout` state for
`pull_request` events. with the previous string-typed `initialBranch`,
both the captured value and the live probe would equal `"HEAD"` on
any detached state, trivially satisfying the invariant — including a
subagent doing `git checkout --detach <sha>`.
discriminate the captured HEAD: probe `git symbolic-ref --short HEAD`
first (works on named branches), fall back to `git rev-parse HEAD`
(SHA) on detached entry. store as
`{ kind: "branch"; name } | { kind: "detached"; sha }`. checkout_pr
runs the identical probe at call time and compares like-with-like
(branch name vs branch name, SHA vs SHA).
refusal error renders both heads via a small `describeHead` helper and
chooses the right `git checkout` recovery target (branch name or SHA).
no inline-discriminant `as` casts — uses a top-level `headsEqual` that
narrows via the discriminator.