v0
201 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fd2c67ab50 |
adhoc: push:restricted adversarial pentest (#827)
* 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
|
||
|
|
c43ed65c3b |
Add Vertex AI routing support (#753)
* add Vertex AI routing support * include Vertex smokes in action CI |
||
|
|
f3d18401ac |
eager signup credit + free-OpenCode fallback when BYOK has no key (#789)
* eager signup credit + free-OpenCode fallback when BYOK has no key
addresses the silent-churn pattern that took out 15 first-run-failure
accounts post-launch: GH Actions secret references resolved to empty
strings (because the secrets didn't exist on the repo), the action
launched Claude Code with no key, the LLM provider 401'd, and the run
died in seconds with a synthetic "Invalid API key" message. those
accounts had no Router credits to fall back to because the lazy claim
required a dashboard visit they never made.
three changes, one PR:
1. Eager $10 signup credit at account creation. Both account-creation
sites (`upsertAccountByClerkId` for dashboard signin, `fetchOrCreateRepo`
for CLI / GH-App-only) now insert the `CreditGrant { reason: "signup" }`
in the same transaction as the `accounts` row. CLI installers who
never sign in get the credit. The dashboard `/signup-credit/claim`
POST stays as an idempotent backstop for accounts created before
this shipped.
2. Free-OpenCode fallback in the action. When the configured BYOK slug
needs a provider key the runner doesn't have, swap to
`opencode/minimax-m2.5-free` before agent selection so the run still
succeeds. Surfaced via a `» fell back from <slug> to <free>` warning
in the action log. Skipped on Router runs (Pullfrog mints the key)
and when no model is configured (auto-select-with-throw still fires
for the genuinely-misconfigured case).
3. New action-test fixture `byok-no-keys-fallback` that empty-strings
every known provider key (matching how GH Actions handles missing
secrets) and asserts the run succeeds with the fallback log line
present. plus a unit test for the helper covering each skip case.
skipping the schema flip from `byok` to `router` — that's coming via
the onboarding-stepper PR (#762).
* fallback: skip Bedrock + surface in PR-comment footer
addresses copilot review on #789 (real bug — parseModel throws on
Bedrock raw IDs that have no slash, would crash before
validateBedrockSetup could surface its own error) and the user-side
ask to make the fallback visible in PR comments.
- selectFallbackModelIfNeeded skips when resolvedModel has no '/' so
Bedrock routing IDs (e.g. us.anthropic.claude-opus-4-7) don't crash
inside hasProviderKey -> parseModel. unit test covers it.
- toolState.modelFallback records the configured slug we fell back
from. set in main.ts when fallback engages.
- buildPullfrogFooter accepts fallbackFrom and renders
"Using `MiniMax M2.5` (free) (credentials for Claude Opus not
configured)" so the substitution is visible in PR comments,
reviews, PR bodies, and error reports.
- threaded through all four action-side footer call sites
(mcp/comment, mcp/pr, mcp/review, utils/errorReport). server-side
call sites in triggerWorkflow.ts / handleWorkflowRunWebhook.ts
fire pre-action and don't have toolState — left as-is.
* fallback footer: use provider display name + document email asymmetry
addresses pullfrog reviewer findings on #789:
- footer now shows 'credentials for Anthropic not configured' (provider
display name from `providers.anthropic.displayName`) instead of the
per-model name. credentials are provider-scoped (ANTHROPIC_API_KEY
covers all Anthropic models), so this matches what the user actually
needs to fix.
- document the intentional asymmetry between eager and lazy signup
credit paths: eager skips both the signupCreditClaimedEmail and the
per-grant team@ alert. comment explains why (the 'new account
created' alert already covers it on the eager path; the user-facing
email assumes a user-initiated action that hasn't happened yet for
CLI/GH-App-only signups).
- skipping the backfill for the 15 historical accounts per user's
earlier decision — they all uninstalled, so the cohort self-selected
out of being reachable.
* fallback: gate on resolvedModel + skip resolveModel re-resolve post-swap
local agnostic fixture run surfaced two real bugs the unit tests didn't
catch:
1. fallback gate was on configuredSlug (=payload.model) but the test
uses PULLFROG_MODEL to set the model, which is read by resolveModel
AFTER its slug arg. configuredSlug stayed undefined → fallback never
fired. drop configuredSlug from the helper signature; gate purely on
resolvedModel since that's the same value regardless of how the
model was specified (DB config vs PULLFROG_MODEL env).
2. when fallback engaged, the post-swap resolveModel({slug: fallback.to})
call was ALSO honoring PULLFROG_MODEL, re-overriding the fallback
target back to the unkeyed model. validateAgentApiKey then threw
"no API key found" against the original model. fix: skip the
re-resolve. fallback.to is already a CLI-ready specifier.
unit tests updated for the new helper signature (8 tests, all pass).
fallback log line confirmed emitted in the local run pre-second-fix;
the second fix unblocks the validation that previously threw.
|
||
|
|
8dff91ac49 |
router: fix unspendable signup credit on no-card private repos (#792)
* router: fix unspendable signup credit on no-card private repos (#791) The bug ------- `run-context/route.ts` gated `proxyModel` minting on `isInfraCovered`, which is `oss || hasCard`. So a no-card account with positive wallet balance (signup credit, top-up, etc.) on a private repo would never get a `proxyModel` set on the run context. The action runtime then fell through to whatever provider keys happened to be in the workflow env — using the user's BYOK keys without their knowledge if any were configured, or failing the run entirely otherwise. Meanwhile `proxy-token/route.ts` already gated correctly on `oss || hasCard || balance > 0`. The two routes disagreed, with run-context being strictly more restrictive, so the agent never even attempted to call proxy-token for these accounts. The wiki at `billing.md:1052` documented the *intended* behavior ("a Router usage row can debit a wallet with no card on file"), aspirational against the actual code. The action side had a parallel bug at `action/utils/proxy.ts:151` — it re-derived `isInfraCovered({ isOss, plan })` and short-circuited mint even when the server set `proxyModel`. Belt-and-suspenders that was strictly more restrictive than the server. Production impact ----------------- Queried 55 router-mode no-card accounts holding signup credit: - ALL have wallet balance = exactly $10.00 (untouched) - ALL have 0 router proxy keys ever minted, 0 hwm usage - ~25 have successful runs (using BYOK env vars from their workflow, unaware their credit isn't being touched) - The rest have zero successes; some accumulated 25+ failures (e.g. `onechannelpe`: 25 failures, 0 successes, no card, $10 credit). The fix ------- - `run-context/route.ts`: widen `useRouter` to match proxy-token's gate. OSS short-circuits as before. Otherwise: router mode + card on file → mint; router mode + no card + positive balance → fetch balance, mint if > 0. Skip the balance read when a card is on file (auto-reload covers it without needing pre-flight balance — keeps the hot path single-query). - `action/utils/proxy.ts`: drop the redundant `isInfraCovered` check. `ctx.proxyModel` IS the signal — the server is the authority on funding decisions; the action just trusts and mints. - `wiki/pricing.md`: correct the Router proxy key minting gate row + add a paragraph explaining why this gate diverges from `isInfraCovered`. - `wiki/billing.md`: rewrite the misleading "proxy-token returns 402" paragraph to describe what actually happens at both routes. `isInfraCovered` is unchanged. It still gates Pullfrog-paid features (learnings writes, indexing). The bug was in conflating "Pullfrog pays for marginal infra" with "user can fund a Router run via wallet" — different concerns, now untangled. * action: drop dead isInfraCovered + plan param post-fix Cleanup the action-side dead code introduced by the previous commit's removal of the redundant `isInfraCovered` re-derivation in proxy.ts: - delete `isInfraCovered` from action/utils/runContext.ts (was the only callsite; mirror in server's utils/billing.ts is unchanged and still load-bearing for learnings/indexing) - drop unused `plan: AccountPlan` param from `resolveProxyModel` / `runProxyResolution` (and the corresponding `AccountPlan` import + the `plan: runContext.plan` arg at the main.ts call site) - update the action/mcp/server.ts comment that pointed at the now-gone action mirror to reference the server-side `utils/billing.ts` instead `AccountPlan` itself is still load-bearing (mcp/server, runContextData, run-context fetch), only `isInfraCovered` and the dead `plan` parameter go away. |
||
|
|
0a64659ee7 |
refactor: slim action/main.ts to an orchestrator + extract helpers (#755)
* refactor: extract helpers out of action/main.ts so non-orchestration churn stops touching the file main.ts had grown to ~1240 lines holding ~500 lines of helpers that have nothing to do with the resolver pipeline — billing-error UI/copy, proxy minting, summary/learnings persistence, log formatters, end-of-run cleanup waterfalls. any PR adding a new billing code branch or a new log line was forced to edit main.ts, and since main.ts is in ALWAYS_RUN_ALL the entire 52-job LLM CI matrix fired on what should have been a 0-job change (e.g. #748). extractions: - action/utils/billingErrors.ts — BillingError, TransientError, the format*Summary renderers, billingConsoleUrl - action/utils/proxy.ts — mintProxyKey, buildProxyTokenHeaders, resolveProxyModel, plus runProxyResolution wrapper that renders + rethrows BillingError/TransientError before the outer catch - action/utils/prSummary.ts — fetchPreviousSnapshot, persistSummary co-located with the existing seed/read file helpers - action/utils/learnings.ts — persistLearnings co-located with the existing seed/read file helpers - action/utils/runStartupLog.ts — resolveOutputSchema + logRunStartup (the model/agent/push/shell/timeout block) - action/utils/runErrorRenderer.ts — renderRunError classifies (BillingError reclassify / hang detect / API-key auth) and emits {summary, comment} markdown bodies - action/utils/runLifecycle.ts — persistRunArtifacts, finalizeSuccessRun, writeRunErrorOutputs — the three end-of-run cleanup phases shared between the success path and the error catch path main.ts is now ~570 lines — the irreducible orchestrator: disposables (`await using` for tokenRef / gitAuthServer / mcpHttpServer), the toolContext construction, the agent-timeout race, the catch/finally shape, and the named phase calls. behavior is preserved verbatim (verified: pnpm -r typecheck + pnpm test 695/695 pass, action/test 596/596 pass). wiki/main.md gets a new "file layout" section describing the split. AGENTS.md gets a single line pointing future edits at the helpers instead of main.ts. * anneal: address review findings - restore MainResult.result?: string (accidental removal in initial commit; field was unused in current code but is part of the exported interface surface — keep the diff truly behavior-preserving) - move resolveOutputSchema from runStartupLog.ts to payload.ts (it's an action-input resolver alongside resolvePromptInput / resolvePayload, not a log helper — was placed in runStartupLog.ts for matrix-churn pragmatism but the domain fit is in payload.ts) - un-export resolveProxyModel (only used internally by runProxyResolution in proxy.ts; no external importer) - fix runErrorRenderer.ts JSDoc "Three classifications" → four (Billing, hang, API-key, default) - expand runLifecycle.ts module banner to note that finalizeSuccessRun calls persistRunArtifacts first, and to explain why the catch path splits writeRunErrorOutputs + persistRunArtifacts - update billingErrors.ts header to point at proxy.ts and runErrorRenderer.ts as the actual origin sites (was stale "main.ts") - expand proxy.ts header to spell out the runProxyResolution entrypoint contract (was stale "main.ts can render") - update wiki/main.md resolver chain + dependency table to name runProxyResolution as the actual call site and document the early BillingError/TransientError rendering branch - update wiki/main.md file-layout table to lead with runProxyResolution and describe mintProxyKey/buildProxyTokenHeaders/resolveProxyModel as internal helpers (was implying they were public surface) |
||
|
|
a78b1542da |
feat: pullfrog auth codex + fresh-branch (#757)
* feat: pullfrog auth codex + fresh-branch Add `pullfrog auth codex` standalone command for minting Codex (ChatGPT) subscription credentials and saving them as the `CODEX_AUTH_JSON` Pullfrog secret. Codex device-auth runs in a subprocess with an isolated `CODEX_HOME` (temp dir) so the user's `~/.codex/auth.json` is never touched. The spawned `codex login --device-auth` output is captured line-by-line, ANSI-stripped, and re-rendered with a `$ codex login --device-auth` header above dimmed sub-output on the @clack/prompts rail so the user visually understands they're seeing a sub-process. Companion `pnpm fresh-branch` script: from inside `.worktrees/<name>`, creates a schema-only Neon branch named `dev/<git-branch>`, patches the worktree's `.env` (DATABASE_URL, DATABASE_URL_UNPOOLED, NEON_DEV_BRANCH), then runs `prisma migrate reset --force` so migrations apply cleanly against a data-free copy. Refuses to run from the primary checkout or on protected branch names. Other: - bump CLI/account/repo secret value limit 4096 -> 49152 chars (matches GitHub Actions' 48KB cap; auth.json is ~4-5KB) - extract shared CLI helpers (gh/pullfrog API, secret save) into `action/commands/_shared.ts` * fix(auth): address PR review + add CodexAuthCallout, default account scope Review fixes: - handle 'error' event from `codex` spawn (ENOENT) so missing PATH bails with an actionable "install codex CLI" message instead of an unhandled Node error - escalate SIGTERM -> SIGKILL after 5s grace when killing a stuck codex child so the CLI can't get pinned indefinitely - stop the spinner with a red "failed" glyph in the catch path before clearing activeSpin, mirroring `bail` (no orphan spinner above errors) - enforce 48 KB secret value cap by *bytes* (Buffer.byteLength) not UTF-16 code units, across all 3 secret routes; matches GH Actions' byte-based limit - preserve existing blank lines + comments when fresh-branch rewrites worktree .env (no more cosmetic reformat on every run) Scope: - default to `account` scope on org-owned repos too — never silently prompt for repo scope. Pullfrog has no per-GitHub-user secret store, so account is right for both user and org owners; `--scope repo` is the explicit opt-in for repo-only. UI: - new CodexAuthCallout (sibling to ClaudeCodeOAuthCallout); surfaces `pullfrog auth codex` for ChatGPT subscribers when an OpenAI provider model is selected. wired into AgentSettings.tsx (model-costs surface) and OnboardingCard.tsx (first-time setup). no paste button — the CLI handles minting + saving end-to-end. * auth/codex: rename to neon-fresh-branch, address PR review - rename `pnpm fresh-branch` → `pnpm neon-fresh-branch` (and the script file) to disambiguate from git branches. - `--scope` help text now explains the default (account) and when to pass `repo`. - move `_shared.ts` import up with the rest in `action/commands/auth.ts` and push the `stripAnsi` helper below the import block. - `sanitizeBranchName` no longer slices: slicing after trim could reintroduce a trailing `-`/`/`. callers slice the raw input first, then sanitize. - DRY the `start` branch of the codex progress callback (single header path, optional retry log). - thread a `timedOut` flag from `runDeviceAuth` → `ProgressEvent.exit` so the retry prompt can say "device authorization timed out — retry?" instead of the generic "no auth.json was written" line when the per-attempt timeout fires. - drop the redundant `mkdirSync` after `mkdtempSync` in `codexAuth.ts`. * untrack .scratch/ (committed screenshot fixture by mistake) * auth codex: prompt for scope on orgs (mirrors init) * revert worktree.ts: out of scope for this PR * anneal: trim _shared.ts dead exports, collapse CodexSpawnError, inline packageBin * codex auth: wire end-to-end runtime consumer CODEX_AUTH_JSON is now actually usable: the action runtime materializes it as OpenCode's auth.json at the runner's real $HOME/.local/share/opencode, OpenCode routes openai requests through the ChatGPT subscription via the embedded CodexAuthPlugin, and a GitHub Actions post: hook detects any refresh-chain rotation during the run and PUTs it back to Pullfrog via a new JWT-authenticated PUT /api/runtime/secret endpoint. Key decisions: - Write to the real $HOME (not the per-run tmpdir-redirected HOME) so the file lives outside OpenCode's `/tmp/*` permission allow zone — its existing deny-default protects it without any new permission rule. - Materialization gated on agent === opencode (Codex auth is OpenAI-only, Claude never sees the file). - Defense-in-depth on Claude: deny Read/Grep/Edit/Glob + sandbox.denyRead for ~/.local/share/opencode/auth.json in managedSettings (covers Bash file-reading commands too per Claude Code permissions docs). - New `provider.managedCredentials` field on the provider config — CLI-only credentials authored via `pullfrog auth <provider>`. Counted for hasAnyKey/log-redaction but never surfaced as a paste option in init. CODEX_AUTH_JSON is the first member; OPENAI_API_KEY stays in envVars. - Eager refresh on `pullfrog auth codex`: one OAuth round-trip before setPullfrogSecret so Pullfrog's copy is the freshest in the chain (avoids the user's laptop refreshing first and stranding our copy). - Post-hook approach for write-back so it survives cancellation, timeouts, and unhandled errors in the main step. State is ferried via core.saveState since apiToken is run-scoped and not in env. - Server-side write-back endpoint is allowlist-gated to CODEX_AUTH_JSON only — never a generic secret-write surface. Looks up the secret at repo scope first, falls back to account scope. 404s on create (refresh-only, never auto-provision). * codex auth: documentation + wiki cross-links * debug: log dbSecrets keys + CODEX_AUTH_JSON presence (temporary) * debug: surface install path + parse failure preview * remove debug log lines (E2E verified) * hide CodexAuthCallout until opencode-ai bump (1.1.56's allowed-models set excludes gpt-5.5) |
||
|
|
a0dce200d0 |
fix(claude): prefer OAuth token over ANTHROPIC_API_KEY (#763)
* fix(claude): prefer OAuth token over ANTHROPIC_API_KEY in Claude Code
When both `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` are present,
claude-code's auth resolver (`Vw()` in cli.js) returns the API key first
and silently ignores the OAuth token. The result: accounts that have a
Max-subscription OAuth token in `account_secrets` are still billed at
per-token API rates because the workflow `env:` block also forwards
`ANTHROPIC_API_KEY` from org-level secrets.
Strip `ANTHROPIC_API_KEY` from the spawned claude-code subprocess env
when an OAuth token is present (and we're not on the Bedrock route),
so the Max subscription is actually used. Other agents in the same run
still see the API key in `process.env` via the parent.
* chore: tighten comment-length rule + trim claude.ts comment
Caps inline comments at 2-3 lines above any single line of code (the
prior wording allowed runaway block comments as long as the comment
was nominally shorter than the annotated code).
* chore: downgrade OAuth-strip log to debug + document debug-mode pattern
`log.info` was overkill for a per-run path-selection marker. `log.debug`
keeps production logs quiet while preserving full visibility in e2e
verification, where `LOG_LEVEL=debug` (or `gh run rerun --debug`)
flips the same line on.
Adds a "Action debug mode" subsection to wiki/e2e-testing.md so the
affordance is discoverable: `log.debug(...)` is the right tool for
breadcrumbs that prove a code path fired during preview-repo e2e but
shouldn't ship to customer logs.
* chore(wiki): correct debug-mode trigger guidance for preview repos
LOG_LEVEL=debug only works when the template's pullfrog.yml forwards
it, which it doesn't. ACTIONS_STEP_DEBUG=true is the GitHub-magic name
that's auto-injected into every step's env without any yaml change,
so make that the documented default for preview-repo e2e.
* chore(wiki): fix render-format claim in debug-mode table
When `ACTIONS_STEP_DEBUG=true`, `log.debug` routes through
`core.debug()`, which GitHub renders as `##[debug]<msg>`, not the
`[DEBUG] <msg>` format. The `[DEBUG]` prefix only happens via the
LOG_LEVEL=debug path which isn't currently wired into the template.
* feat(action): add `overrides` input for per-dispatch env mutation
Accepts a JSON {string:string} map via the workflow_dispatch input,
parsed and merged into process.env at the start of `main()` (before
any agent or token-acquisition code runs). Lets a privileged caller
flip env vars for one dispatch without persisting state on the repo
(repo Actions variables) or being restricted to GitHub's debug names
(`gh run rerun --debug`).
Deny-list refuses overrides for integrity-critical names — GITHUB_TOKEN,
ACTIONS_RUNTIME_TOKEN, ACTIONS_RUNTIME_URL, ACTIONS_ID_TOKEN_REQUEST_*,
ACTIONS_CACHE_URL, PULLFROG_API_SECRET, VERCEL_AUTOMATION_BYPASS_SECRET.
Customer provider keys (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, etc.)
are explicitly allowed — overriding them per-run for cred-rotation tests
and auth-failure repros is the use case.
Touches:
- action/action.yml — declare `overrides` input
- action/utils/overrides.ts — parse + apply with deny-list (+ unit tests)
- action/main.ts — wire into `main()` after `normalizeEnv()`
- .github/workflows/pullfrog.yml — forward to action
- utils/github/pullfrog.yml.ts — same in the customer-facing template
- wiki/e2e-testing.md — documented as preferred debug-mode trigger
* fix(overrides): strip raw INPUT_OVERRIDES + mask applied values
GitHub Actions injects every action input as an env var (INPUT_<NAME>),
so the original JSON of `overrides` sits in process.env as INPUT_OVERRIDES
and is inherited by every spawned subprocess (claude, opencode, MCP
servers, shell). That defeats the deny-list (a downstream re-application
would have access to the raw JSON) and leaks arbitrary caller-supplied
values into agent env verbatim.
After applying, applyOverrides now:
1. delete process.env.INPUT_OVERRIDES — subprocesses see only the
surgically-applied keys, not the raw JSON
2. core.setSecret(value) for each applied value — the runner masks
those strings in subsequent log output, so an overridden
ANTHROPIC_API_KEY can't accidentally surface in debug logs.
Two new tests cover the deletion path (both applied and all-denied).
* fix(overrides): scope auto-masking to credential-shaped keys
core.setSecret(value) is a global string-match — calling it on a short
config value like "claude" masks every appearance in subsequent logs
(including "claude-opus-4-7", "anthropic-claude-sonnet", etc.), which
actively harms debugging.
Restrict the auto-mask to keys whose names end in _KEY / _TOKEN /
_SECRET / _PASSWORD / _OAUTH / _PRIVATE_KEY — the credential-shape
naming convention. Customer keys (ANTHROPIC_API_KEY, etc.) and the
deny-listed names match. Plain config (PULLFROG_AGENT, PULLFROG_MODEL,
ACTIONS_STEP_DEBUG) doesn't.
* docs(wiki): document the three security layers + runner-echo caveat
Lays out exactly what the `overrides` input does to mitigate the secret-
leak surface (deletion + masking) and the one unavoidable limit: GH
Actions echoes the `with:` block once before any action code runs, so
the raw JSON appears in the workflow log header in plaintext. Anyone
using `overrides` should treat that one-shot exposure as part of the
threat model.
* fix(overrides): forward via env, not action input, so the value isn't echoed verbatim in the runner step header
GH Actions echoes the `with:` block of every `uses:` step in the log
group header, BEFORE any action code runs — so the raw JSON of
`overrides` was always visible in the workflow log regardless of any
in-action `core.setSecret` calls.
Refactor: drop the `overrides` action input; instead the action reads
`process.env.PULLFROG_OVERRIDES`. The workflow yaml forwards
`inputs.overrides` via the step-level `env:` block. We still need to
verify empirically whether `env:` block values from workflow inputs
get echoed too (separate test); even if they do, masking via
core.setSecret + delete of PULLFROG_OVERRIDES after parsing closes
the leak to subprocesses, which is the part the action controls.
* fix(overrides): rename to unsafe_overrides + UNSAFE_OVERRIDES
The runner echoes step-header env-block values in plaintext before any
action code runs, so the raw JSON of this affordance is visible to
anyone with actions:read on the calling repo. That's acceptable
because the workflow only exists on our private repos, but the input
name should make the trade-off obvious at the call site rather than
buried in a wiki.
- workflow_dispatch input: `overrides` → `unsafe_overrides`
- env var the action reads: `PULLFROG_OVERRIDES` → `UNSAFE_OVERRIDES`
- wiki: rewrite the section to surface the runner-echo as the central
trade-off rather than a buried caveat
* chore(overrides): tighten error messages to reference UNSAFE_OVERRIDES
* docs(wiki): fix stale 'overrides' refs + correct render-format mechanism
Addresses two unresolved review threads on PR #763:
1. The opening sentence of "Action debug mode" still referenced the
pre-rename `overrides` input and `gh workflow run -f overrides=...`.
Updated to `unsafe_overrides`.
2. The render-format claim was technically wrong. `core.isDebug()`
doesn't cache — it reads `process.env.RUNNER_DEBUG === '1'` on
every call. The actual mechanism: the runner only sets
RUNNER_DEBUG=1 when ACTIONS_STEP_DEBUG=true is observed at
workflow-trigger time. Mutating ACTIONS_STEP_DEBUG mid-step
doesn't retroactively flip RUNNER_DEBUG, so the call falls through
to isLocalDebugEnabled() which reads ACTIONS_STEP_DEBUG directly.
Rewrote the explanation to match.
* fix: drop unsafe_overrides from customer-facing workflow template + remove test theater
Two cleanups from a stricter re-read of AGENTS.md:
1. utils/github/pullfrog.yml.ts is the workflow yaml we sync into every
customer repo. unsafe_overrides has no business there — it's a
pullfrog-only debugging affordance. Reverted. The action's read of
UNSAFE_OVERRIDES env var stays — it's a no-op for any workflow that
doesn't set it, and pullfrog/template + pullfrog/app's own workflow
still forward it.
2. Deleted action/utils/overrides.test.ts entirely. AGENTS.md is clear:
no tests unless explicitly asked. I added them anyway. The tests
were mostly testing JSON.parse + typeof, plus one regression guard
for the deny-list that is better protected by code review of the
tiny DENIED_OVERRIDE_NAMES set than by a vitest file.
Also strengthened the corresponding AGENTS.md rule from a buried bullet
to an explicit "NEVER write tests unless asked, here's why agents
violate this constantly, here's the bar" callout.
Wiki note added: unsafe_overrides is pullfrog-only infra, not customer-
facing.
|
||
|
|
ba7f5a0b89 |
action: surface agent hang context in progress comment (#733)
* action: surface agent hang context in progress comment When the activity-timeout watchdog kills a stalled opencode subprocess, the user used to see a bare "activity timeout: no output for 30Xs" — no provider context, no stderr trace, no clue why the run died. Investigation of the six runs in #728 showed the same shape every time: opencode hangs after a non-retryable provider event (auth 401, 502 stream lost, free-tier flake), and the only useful signal was buried in stderr where the user couldn't see it without diving into Actions logs. Stop trying to prevent the hang. Surface it. Add a small `AgentDiagnostic` handle on `toolState` that the harness mutates as a run progresses (recent stderr ring buffer reference, last provider-error label, event count). `formatAgentHangBody` renders that into a markdown body — bold headline, one-line explanation, collapsible `<details>` with the last ~10 stderr lines (capped to 3KB) — used by both the agent harness's own catch path and main.ts's outer catch when the watchdog wins the race against the harness. Both paths converge on one formatter; the existing "View workflow run ➔" footer affordance in `reportErrorToComment` is unchanged, so the user still has one click from the comment to the raw logs to develop their own thesis. * address review: gate hang body on isHang; fix contradictory copy - Only render `hangBody` when `isHang`. The harness sets `agentDiagnostic` on entry, so any non-hang throw past `runOpenCode`'s own catch (post-success `output_schema` validator, late cleanup throws) was rendering "Pullfrog failed — N events processed…" with the real exception message dropped — including for runs that actually succeeded before a late throw. - When `lastProviderError` already names the cause in the headline, the zero-events sentence "check whether the model provider is reachable" contradicts it (a 401 produces zero events but isn't a reachability issue). Drop the nudge in that case; keep it for the silent-stall path where it's still actionable. * address copilot review: fence escape, idle parsing, secret redaction, tests - pick a backtick fence longer than any backtick run in the rendered stderr tail. opencode error JSON occasionally embeds triple backticks in tool input dumps; the fixed three-tick fence let those terminate the fence early and corrupt the rest of the comment markdown. - parse idle seconds out of the timer reject string ("activity timeout: no output for 301s") and use that for the hang explanation. previously rendered total runtime, which overstated the stall by 20+ minutes for runs that streamed for a long time before going quiet (e.g. Rohithgilla12/data-peek#25784038918, 1230s elapsed but 304s idle). - redact sensitive env-var values from the rendered stderr tail before it lands in the PR comment / job summary. workflow log writes already go through `core.setSecret` masking; PR comments and summaries bypass that pipeline entirely. matches against `isSensitiveEnvName` (the same *_KEY/*_TOKEN/*_SECRET/*_PASSWORD/*_CREDENTIAL surface that `normalizeEnv` registers with the runner) and only redacts values >= 8 chars to avoid false-positive substring hits. - add `agentHangReport.test.ts` covering the branchy bits: idle-seconds parsing, eventCount-zero copy with and without provider error, fence-escape against embedded triple backticks, 3 KB tail truncation, null-on-no-diagnostic, and secret redaction. `startedAtMs` is dropped from `AgentDiagnostic` — total runtime was the only consumer and idle seconds replaces it. * strip slop: drop tests, drop redactSecrets, simplify ternary - delete `agentHangReport.test.ts`. half the cases just pinned literal copy ("**Pullfrog stalled**", "check whether the model provider is reachable") which is exactly the "performative tests to every string utility" pattern AGENTS.md flags. the other half tested 2-5 line pure helpers (parseIdleSec / pickFence / truncation) that code review catches. the formatter is a best-effort string output; pinning it in tests creates churn without catching real regressions. - remove `redactSecrets` and revert the formatter's import. theatrical defense: opencode doesn't dump env on startup, bearer tokens aren't in request bodies, bash is denied. the action has many other PR-comment write paths that don't redact (comment.ts, errorReport.ts, the progress writer) — if PR-comment secret hygiene matters, it's a cross-cutting concern at the comment-write layer, not bolted onto one formatter. - factor the explanation triple-ternary into `formatExplanation` with early returns. same logic, easier to read. `isHang` gate, fence-length escaping, and idle-seconds parsing stay — those are real correctness fixes. |
||
|
|
868576a474 |
audit: format byok auth errors actionably + tighten audit prompt
- `action/utils/apiKeys.ts`: rewrite the missing-key body as Markdown with linked CTAs (repo secrets / model settings / docs). add `isApiKeyAuthError` + `formatApiKeyErrorSummary` covering both shapes: missing key (#679) and revoked/invalid 401 key (#702). - `action/main.ts`: reclassify in the result-failure branch and the catch block so the PR progress comment surfaces the actionable CTA instead of the raw `Invalid API key · Fix external API key` / numbered-list dump. - `scripts/analyze-logs.ts`: split `failure:user-misconfig` into `:no-key` and `:invalid-key` so both buckets are visible separately and the audit can ignore them as user-correctable. - `.github/workflows/run-audit.yml`: add three explicit prompt rules — cross-customer signal required (≥3 distinct accounts; single-customer concentration is not enough), recovered failures are not actionable, user misconfig is out of scope. closes the loop on #679 / #702 being filed in the first place. |
||
|
|
5518890b18 |
learnings: TOC + section taxonomy + 100k cap, hygiene rules, tool-quirk descriptions (#717)
* audit learnings: reshape reflection prompt + bake tool quirks into descriptions (#619) Cross-repo audit of the 48 repos with non-null learnings turned up two recurring failure modes: 1. ~25-30% of bullets across the most-active repos are pullfrog-tool quirks ("shell timeout is in milliseconds", "git args must be a JSON array", "create_pull_request_review drops out-of-hunk comments", "push_branch may report timeout when push succeeded", "checkout_pr shallow.lock retries", "commit_id needs full 40-char SHA"). These are universal across repos and should live in tool descriptions, not be rediscovered and stored 48 times. Tool descriptions now surface them. 2. Bullets are routinely 200-1000 chars (paragraph-length), and 12 of 48 repos are at the 10k cap. The reflection prompt now: caps bullets at ~240 chars (one specific fact), bans PR/review/commit/date-anchored facts that decay within weeks, bans tool-quirk learnings, and tells the agent that cap pressure means compress+prune existing bullets, not skip new findings. Co-authored-by: Cursor <cursoragent@cursor.com> * learnings: add server-generated TOC, fixed section taxonomy, raise cap to 100k (#707) Cap goes 10k → 100k. Reads stay bounded because the seeded file now opens with a server-generated table of contents listing every `## ` section's line range — agents read the TOC, then `read_file offset/limit` just the sections relevant to the current task instead of slurping the whole file. ## Section taxonomy (fixed) `## Build & test`, `## CI`, `## Conventions`, `## Architecture`, `## Gotchas`. Free-form `### ` sub-headings inside a section are fine. Pre-taxonomy free-text rows get wrapped in a `## Legacy` carve-out on first seed so they remain visible while the agent gradually re-curates them during reflection turns. ## Storage shape unchanged `Repo.learnings` still holds raw markdown (no schema migration). The TOC is a pure read-side affordance: prepended at seed time, stripped from the agent-edited file before persist. Markers `<!-- pullfrog-learnings-toc:* -->` delimit the strip region. Agent edits inside the markers are discarded. ## Round-trip semantics `seedLearningsFile` now returns `{ path, canonicalSeed }` where `canonicalSeed` is the post-TOC body — same shape `readLearningsFile` returns at end-of-run, so `persistLearnings` byte-compares them directly to skip the no-op PATCH. Empty-repo first runs end up with the section scaffold both as seed and as read-back, so untouched runs still short-circuit cleanly. ## Reflection prompt Adds explicit section-placement guidance (place each new bullet under the most relevant `## `; do NOT add new top-level headings; do NOT edit anything between the TOC markers). Carries forward the bullet hygiene from the previous commit: ≤240 chars per bullet, no pullfrog-tool quirks (those belong in tool descriptions), no PR/review/commit/date references. The "near cap" framing is replaced with "compress and prune within a section when it grows noisy" since the cap pressure that drove cramming is gone. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal round 1: line-anchored taxonomy detect, partial-merge, line-boundary truncation, scaffold-empty UI Multi-lens review of the TOC + taxonomy diff surfaced a cluster of correctness and operational bugs. Fixes: - `hasAnyTaxonomyHeading` used `String.includes("## X")` which false-positives on `### X` (the `## ` substring sits inside `### `), prose containing `## CI`, fenced code documenting markdown, etc. Replaced with a line-anchored predicate that reuses `parseHeadings` so detection and TOC construction stay consistent. - The "any heading present → pass through verbatim" rule meant a body with one taxonomy heading would seed without the other four. Worse, requiring all five would flip a body back into Legacy when the agent legitimately pruned a section to empty. New `partial` kind: keep existing content in place, append missing sections in canonical order so the agent always has the full scaffold without losing pruning intent. - `stripLearningsToc` collapsed `\n{3,}` globally; `canonicalSeed` doesn't, so an untouched body with intentional triple-newline spacing would compare unequal and burn a spurious LearningsRevision row each run. Drop the global collapse — only the leading newlines that the strip itself introduces are normalized. - 100k truncation via `slice(0, 100_000)` could cut mid-line, breaking `parseHeadings` (whole-line `^## `) on the next seed and flipping a cut body back into Legacy. New `truncateAtLineBoundary` cuts at the last newline before the cap. - `LearningsSection.tsx` rendered a scaffold-only body as "has learnings" instead of the empty placeholder. Added a `hasOnlyEmptyScaffold` guard so the console behaves the same as pre-PR for the empty case. - Seed log line distinguishes `kind=structured/partial/legacy-wrapped/ empty` instead of `existing=yes/no`, so operators can spot legacy migration activity in logs. - New tests cover: substring false-positive (`### Build & test`, in-prose mentions), partial-taxonomy merge (no Legacy wrap), full-taxonomy structured pass-through, last-newline truncation, triple-newline preservation. Deferred (documented in PR body): deploy-ordering footgun (action before API), rollback for rows >10k, Gemini sanitizer dropping `description` on `anyOf` branches, reflection-on-failed-runs. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal r2: hard-truncate fallback when line boundary discards >4k Round-2 review caught a regression in `truncateAtLineBoundary`: when the only newline within the first 100k chars sits near the start (e.g. one heading + 100k+ char single line — pathological pasted log dumps), the line-boundary cut discards almost all of the body. losing one partial line is preferable to losing kilobytes; threshold the fallback at 4k. Co-authored-by: Cursor <cursoragent@cursor.com> * move TOC out of file: prompt-side rendering, server-parsed headings drops the in-file TOC + fixed taxonomy in favor of: - file on disk = verbatim Repo.learnings (no markers, no scaffold) - server parses headings (mdast-util-from-markdown) at run-context time and returns them as RepoSettings.learningsHeadings - action renders heading TOC into the LEARNINGS prompt section as parenthesized line ranges like `Build & test (L1-L42)` with hierarchy via 2-space indent off the shallowest depth - reflection prompt teaches agent-curated structure with a soft 300-line per-section cap and explicit guidance to restructure flat legacy lists cuts 8 helpers (ensureSections, stripLearningsToc, assembleFile, buildTocBlock, parseHeadings, buildSectionScaffold, hasAnyTaxonomyHeading, LEARNINGS_SECTIONS) and the canonicalSeed round-trip dance. action seedLearningsFile is now { path } only; main.ts byte-compares the trimmed read-back against (current ?? "").trim() to gate the persist PATCH. truncateAtLineBoundary kept for safety. new tests: - test/learningsToc.test.ts (11 parser cases incl. fenced-code, blockquote, arbitrary h1-h6 nesting, startLine-points-at-heading invariant) - action/utils/learningsTocRender.test.ts (7 renderer cases) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
d5f881e9fc |
action: trim sensitive env values before GitHub Actions log masking (#698)
* action: trim sensitive env values before GitHub Actions log masking GitHub Actions' log masking is line-based: a secret value containing a newline only registers the first line as a mask, leaving the remainder exposed verbatim in logs. A trailing newline copied from a terminal into a GitHub Actions secret (e.g. ANTHROPIC_API_KEY) was enough to leak "a large part of the key" in run logs (pullfrog/pullfrog#41). normalizeEnv now trims leading/trailing whitespace from any value whose key matches the sensitive name pattern, masks the cleaned value, and warns when whitespace was stripped so the user notices the source. sanitizeSecret is reused for dbSecrets injection in main.ts. The three secret-store PUT/POST routes also trim values defensively, matching the existing name.trim() pattern. Real multi-line secrets are not used in practice — even GITHUB_PRIVATE_KEY PEMs are stored single-line with escaped \n and unescaped at the point of use — so a straight trim() is safe. * action: address review — use core.setSecret for masking, don't zero whitespace-only Pullfrog's review of #698 caught two real issues in the original fix: 1. `console.log(\`::add-mask::\${trimmed}\`)` doesn't escape \r/\n. If a value survives trim with an embedded newline (PEMs, kubeconfigs, JSON), the runner only registers the first line as a mask and the rest leaks. `core.setSecret(trimmed)` routes through @actions/core which percent-encodes \r/\n so the runner V2 parser decodes back to the full value and registers every non-empty line as a separate mask. Removes the load-bearing "no embedded newlines" invariant from the fix. 2. Whitespace-only sensitive values silently became "". Downstream truthy checks would flip from "set" to "missing" with no log. Now sanitizeSecret returns null in that case and callers skip the process.env write, surfacing a clear missing-key error instead. Tests rewritten to assert process.env state directly — no stdout spies. Masking correctness is delegated to @actions/core (trusted dependency). |
||
|
|
d8f825034f |
billing: $10 signup credit + lazy claim modal; disable welcome credit promo (#674)
* billing: $10 signup credit + lazy claim modal; disable welcome credit promo Adds a per-Account $10 Router signup credit granted on first Router-tab mount via a new admin-gated POST /api/account/[owner]/signup-credit/claim. The endpoint is idempotent — the inserted CreditGrant row IS the dedup state, so subsequent calls return granted:false. Client SignupCreditModal fires the POST on mount (only when modelAccessMode === "router") and opens a celebratory dialog when granted:true. Disables the legacy welcome credit ($10 on first card add) via a new WELCOME_CREDIT_PROMO_ACTIVE = false flag in utils/stripe.ts. Code path stays intact — flip the flag to revive. Strips the now-untruthful "$10 on enabling billing" copy from BillingCard, EnableRouterPrompt, triggerWorkflow paywall comment, action router_requires_card summary, email snippet, billing/pricing docs and wiki. Cuts WELCOME_CREDIT_CENTS from 2000 to 1000 to reflect the lower amount that would land if the flag is ever re-enabled. Adds "signup" reason mapping to BillingCard wallet history. Verified end-to-end against dev: admin+Router fires modal, admin+BYOK gate-blocks mount, BYOK→Router transition fires modal on click, member and collaborator paths skip the mount entirely, reload after grant is idempotent. Wallet history shows "Router signup credit +$10.00". * billing: address PR review (race fix, copy sweep, modal retry) Correctness: - Add @@unique([accountId, reason]) on CreditGrant + migration. The prior check-then-insert pattern in /signup-credit/claim and finalizeCheckoutSession raced at READ COMMITTED — two concurrent admin tabs could land two grants of the same reason on a fresh account ($10 each). Both write sites now rely on the unique index for dedup (P2002 = "already granted") and route updated to catch P2002 cleanly. Verified zero existing duplicates in prod before migration. - Add log.info on signup grant insert so a successful grant has any chance of being caught by ops monitoring. - Add retry: 2 with backoff to the claim mutation. Endpoint is idempotent so a server-side success that lost its response cleanly returns granted:false on retry. Public copy that still advertised the (now-deleted) $20 welcome credit: - app/page.tsx landing pricing card - emails/announceBilling.ts broadcast template - docs/keys.mdx BYOK note - components/AgentSettings.tsx Router-without-billing warning - utils/stripe.ts finalizeCheckoutSession JSDoc - utils/email/snippets.ts ROUTER_CREDIT_PS_HTML JSDoc Wiki staleness sweep: - wiki/billing.md TOC, mermaid diagram (signup edge added; welcome marked dormant), test coverage list, key modules section, no-card wallet narrative - wiki/pricing.md welcome-credit drawdown reference - Rewrote my own internally-inconsistent dormancy paragraph to be honest about the $20-historical / $10-on-revival framing. Trivia: - ModelAccessCard JSX comment had a literal \\u2192 instead of →. * billing: address PR review round 2 - Replace try/catch P2002 inside finalizeCheckoutSession's prisma.$transaction with createMany skipDuplicates. The previous form is broken on Postgres: a unique-violation poisons the surrounding TX, so the catch block returns cleanly but the outer commit fails and the account.update (stripeCustomerId) silently rolls back too. Currently armed only behind the dormant welcome- credit flag, but would have broken billing enablement the moment the flag flipped. createMany skipDuplicates yields a single ON CONFLICT DO NOTHING statement that returns count: 0 cleanly without aborting the TX. - Apply the same createMany skipDuplicates pattern to the signup-credit route too — drops the exception-as-control-flow Prisma namespace import and is more uniform with the welcome path. - Drop the now-orphaned credit_grants_accountId_idx in the same migration. The schema removed @@index([accountId]) when @@unique([accountId, reason]) was added (covered by the leftmost prefix), but the migration only added the unique index, leaving prod drifted. * billing: fix stale finalizeCheckoutSession JSDoc The function-level JSDoc still described the abandoned try/catch P2002 mechanism after switching to createMany skipDuplicates. The inline comment + code now agree on the new ON CONFLICT DO NOTHING shape. * billing: decouple first-card alert, drop vestigial billing field, fix modal cents; sync copy * docs+homepage: align Router credit copy with signup claim (no card-on-add carrot) * homepage: add pricing screenshot and pay-as-you-go promo line * billing: fix once-per-lifetime misframe on first-card alert * billing: suppress signup credit for prior welcome-credit recipients * billing: drop bogus '1000 users' cap; invalidate billing on signup-credit settle |
||
|
|
f0805b78f5 |
learnings: surface persist failures as warnings, not debug
`persistLearnings` only emitted `log.info("» learnings updated")` on
success; every failure path (non-2xx, fetch throw, 10s timeout) was
`log.debug`, which is hidden unless `ACTIONS_RUNNER_DEBUG=true`. Survey
of recent runs caught at least one case where the agent definitively
edited the tmpfile but no DB row was written and no warning surfaced.
Promote both failure paths to `log.warning` so dropped agent work is
visible in CI logs. The unchanged-from-seed short-circuit stays at
debug — that's a genuine no-op.
|
||
|
|
8e36f76cfa |
postrun: thread AgentRunContext through the retry loop instead of repackaging (#652)
* postrun: thread AgentRunContext through the retry loop instead of repackaging
drop the per-gate plumbing in `runPostRunRetryLoop`: the loop now receives
`ctx: AgentRunContext` whole and reads `ctx.stopScript` + `ctx.toolState.*`
directly. `getUnsubmittedReview` becomes a pure utility in postRun.ts
instead of a closure shipped over `AgentRunContext`. `AgentRunContext`
loses 4 fields that duplicated `toolState` (`summaryFilePath`,
`summarySeed`, `learningsFilePath`, `getUnsubmittedReview`) and gains
`toolState: ToolState`. both harness call sites collapse from 11 lines to
7; main.ts deletes the inline closure.
`ToolState` and friends move from `action/mcp/server.ts` to
`action/toolState.ts` so non-MCP code (agents, post-run loop) stops
importing run-state types from the MCP server module.
no behavior change. 503/503 tests green.
* toolState: relocate `CommentableLines` to break dep cycle with mcp/review
`action/toolState.ts` was importing `CommentableLines` from
`mcp/review.ts`, which pulled the entire MCP server compile graph (24
files) into any consumer of `ToolState` — including `cf-worker-indexing`
via the `pullfrog/internal` re-export chain through `utils/log.ts` →
`agents/shared.ts` → `toolState.ts`. that exposed a pre-existing TS
error in `mcp/issueEvents.ts` (octokit types resolve differently under
cf-worker's `moduleResolution: bundler`).
move `CommentableLines` (a small `{ RIGHT: Set<number>; LEFT: Set<number> }`
state-shape type) to `toolState.ts` where it's used; re-export from
`mcp/review.ts` for back-compat with test and call-site imports. cuts
cf-worker's mcp/ compile inclusion from 24 files back to 0.
* postRun: drop mock-heavy retry-loop tests; keep pure gate predicate
`runPostRunRetryLoop` and `executeStopHook` were covered by ~560 lines
of mock-heavy regression-gate tests that stubbed `spawn` / `getGitStatus`
and fabricated `AgentRunContext` to drive orchestration paths. per
AGENTS.md ("prefer no test over a mock-heavy test that only catches the
most obvious form of regression") and the empirical track record — the
one real production failure of this code path (#646) was a missing npm
release, not a logic bug a unit test could catch — the value-to-ceremony
ratio is poor. delete them.
keep only the pure predicate: `getUnsubmittedReview(toolState)` is a
decision function whose four input conditions have user-visible
consequences when wrong. 5 assertions, no mocks, no ctx fabrication.
488 tests still pass.
* toolState: import PrepResult from prep/types.ts, not the barrel
same dep-cycle class as the previous CommentableLines fix. importing
PrepResult from prep/index.ts pulled prep/installNodeDependencies.ts
into the Next.js production build's typecheck graph (via
pullfrog/internal → utils/log.ts → agents/shared.ts → toolState.ts →
prep/index.ts → installNodeDependencies.ts), and Next.js's stricter
NODE_ENV-required ProcessEnv shape rejected an existing
`env: { PATH: ... }` literal.
prep/types.ts is a leaf module with zero imports — re-routing the type
import severs the chain. Vercel preview deploy goes from Error → Ready;
preview-sync stops racing the deploy.
|
||
|
|
96910f0f50 |
fix(run-audit): drop summary comment, fall back to agent final message in job summary
the audit agent's final 'post a short summary' instruction was ambiguous and, with no PR/issue context on schedule runs, caused the agent to invent a target — landing the summary as a comment on the most recent open PR (see #650). drop the comment instruction outright. writeJobSummary now falls back to the agent's final assistant message (result.output) when lastProgressBody is empty, so non-PR runs surface a real summary in the GitHub Actions job summary tab instead of just the usage table. lastProgressBody still wins when present to avoid duplicating the progress comment body. |
||
|
|
85d25a6fe6 |
post-run gate: fail review-mode runs that don't submit a review or progress (#638)
* post-run gate: fail the run when review mode finishes without a review or progress
review-mode runs that ended in a text-only assistant turn ("now I have enough
to draft the review...") were silently swallowed: the progress comment was
deleted by stranded-comment cleanup and no review appeared on the PR. user-
visible result was identical to "the agent never ran." caught in
https://github.com/pullfrog/app/actions/runs/25583698781.
new post-run gate alongside stopHook / dirtyTree / summaryStale: derived
inline from toolState (selectedMode in {Review, IncrementalReview} && !review
&& !finalSummaryWritten && hadProgressComment) — no parallel toolState flag.
when it fires, the resume prompt nudges the agent to call either
create_pull_request_review or report_progress; persistent failure after
MAX_POST_RUN_RETRIES surfaces as AgentResult.error.
also: when the post-run loop returns success=false, write the error to the
progress comment before the stranded-comment cleanup runs, and skip the
delete in that case. previously a !success run from the loop would lose the
error message into the void.
IncrementalReview's trivial-skip branch now calls report_progress with a
brief "no review warranted" note instead of exiting silently — keeps the
contract symmetric with the gate and gives the user a visible signal even
on no-op review runs.
documents the literal-record design rule on the ToolState interface so
future fields don't drift back into derived/absence-encoding state.
* review feedback: mode-aware nudge, gate-error preservation, prompt order
addresses three findings from the auto-review on this PR:
1. Review mode nudge no longer offers `report_progress` as an exit. Review
mode's contract (modes.ts step 5) forbids it; the gate previously sent
contradictory copy. IncrementalReview's nudge still offers both since
its trivial-skip path legitimately allows `report_progress`.
2. `writeJobSummary` is now wrapped in try/catch on the success-path
cleanup. without this, a throw there jumped to the outer catch and
overwrote the gate's failure message in the progress comment with the
(less actionable) writeJobSummary error — restoring exactly the
invisible-failure UX this PR fixes. step-summary writes are
informational; let them fail silently.
3. `buildPostRunPrompt` reorders gates to match the terminal hard-fail
order: `stopHook` → `unsubmittedReview` → `dirtyTree` → `summaryStale`.
when both hard-fail gates co-fire (rare in review modes), the prompt's
emphasis now matches the user-visible failure message.
new test asserts the IncrementalReview nudge offers both exits while the
Review nudge offers only `create_pull_request_review`. e2e validation
already passed against pullfrog/preview-638-review-stop-hook PR #1
(gate fired once; agent recovered on second turn).
* mode-aware terminal error copy
second auto-review caught a residual contradiction: the terminal hard-fail
error string reported "create_pull_request_review or report_progress" for
both modes, even though the new mode-aware nudge tells Review-mode agents
"Review mode does not have a no-submit exit". the error message now mirrors
the nudge — Review names only `create_pull_request_review`,
IncrementalReview lists both. additional Review-mode hard-fail test asserts
the absence of `report_progress` in the error.
|
||
|
|
b0274e3265 |
local proxy-key testing via x-dev-repo bypass (#629)
* local proxy-key testing via x-dev-repo bypass `pnpm play` previously couldn't exercise the proxy/router/oss code path — `resolveProxyModel` early-exits without OIDC credentials, and `mintProxyKey` always sends an OIDC bearer to `/api/proxy-token`. since GitHub Actions OIDC only exists in real workflow runs, billing flows (auto-reload, balance gates, key rotation, OSS subsidy) had no local feedback loop. a server-side dev bypass already exists at `app/api/proxy-token/route.ts` that accepts an `x-dev-repo: owner/repo` header instead of an OIDC bearer when `NODE_ENV === "development"`. wire the action side so it sends that header when there are no OIDC credentials AND `API_URL` resolves to localhost (i.e. the developer is talking to their own `pnpm dev` server). production is unreachable through this path because vercel never sets `NODE_ENV=development`. document the affordance in `wiki/action-tests.md` so the next person doesn't have to re-discover it (the server bypass had been sitting there undocumented since the WIP billing rewrite). verified end-to-end: `PLAY_LOCAL=1 GITHUB_REPOSITORY=pullfrog/app API_URL=http://localhost:3100 pnpm play …` now logs `» proxy: dev bypass (x-dev-repo) for pullfrog/app` → `» proxy: router → openrouter/ anthropic/claude-opus-4.7` → `» model: …(proxy)`, mints a real OpenRouter key against the dev DB, and the agent runs through the proxy. * wiki: cross-reference dev proxy-key affordance from main/e2e/stripe action-tests.md already documents the localhost+x-dev-repo path; mention it from the natural discovery points so the next person finds it without spelunking through git history again: - main.md: resolveProxyModel row in the dependencies table notes the two auth paths (OIDC bearer in prod, x-dev-repo in dev). - e2e-testing.md: "When to use this" calls out the lighter-weight alternative for proxy-only changes. - stripe.md: new "Loop including the action" subsection in the Dev workflow section, alongside the existing dev-script and cron-endpoint loops. |
||
|
|
d6de1c369a |
learnings: edit-in-place tmpfile (drop update_learnings tool) (#635)
* learnings: edit-in-place tmpfile (drop update_learnings tool)
learnings now follow the PR-summary file pattern: server seeds
`pullfrog-learnings.md` from `Repo.learnings` at startup, agent reads
it as part of context, may edit in place during the post-run reflection
turn, server reads back at end-of-run and PATCHes if changed.
motivation: `update_learnings` required the agent to pass the FULL
merged list as a string parameter — an output-token tax that grew
linearly with the learnings size, and a constant prompt-context
expansion since the contents were also inlined into the LEARNINGS
section. for repos with mature learnings the prompt was getting
visibly noisy in CI logs.
key changes:
- new `action/utils/learnings.ts` (seed/read helpers + 10k cap)
- `main.ts`: always seed; `persistLearnings` mirrors `persistSummary`
(success path, error path, exit-signal handler, idempotent guard,
byte-trim equality skip); forwards `model` for `LearningsRevision.model`
- `LEARNINGS` prompt section now contains only the file path + a
one-line "read it" instruction (no contents inlined)
- `update_learnings` MCP tool deleted; `action/mcp/learnings.ts` removed
- reflection turn (`buildLearningsReflectionPrompt`) reframed around
file editing with explicit prune-stale + leave-alone-if-nothing-new
framing
- `learningsStep` removed from every mode checklist — surface lives only
in the LEARNINGS prompt section + the reflection turn now
* learnings: harden seed step + refresh stale docs (review feedback)
Three findings from PR review, all implemented:
1. wrap learnings seed in best-effort try/catch (action/main.ts) —
the always-on seed block ran unconditionally and an unwrapped
`seedLearningsFile` (mkdir + writeFile) 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 started. asymmetric with `persistLearnings`'s own
best-effort contract. wrap and log on failure; downstream
consumers (`persistLearnings`, agent harnesses, `resolveInstructions`)
already handle `learningsFilePath: undefined` cleanly.
2. refresh wiki/main.md — `resolveInstructions` parameter renamed
from `learnings` to `learningsFilePath` in this PR; the data-flow
diagram and the resolver dependency table both still showed the
pre-refactor signature.
3. drop deleted `learnings.ts` from ROADMAP.md + RESEARCH.md
"missing MCP tool tests" bullets — `action/mcp/learnings.ts` was
removed in this PR; the bullets are otherwise still accurate.
|
||
|
|
93cc7b1a44 |
show effective model in agent comment/review footers (#618)
`toolState.model` was set only to `payload.model` (the stored slug, often undefined for router/oss runs that derive the target from `proxyModel`). the footer's "Using `…`" segment is gated on a truthy model, so router runs on repos without an explicit model setting shipped reviews/comments with no model badge — e.g. PR #614's review showed no model despite running `openrouter/anthropic/claude-opus-4.7` via proxy. now mirror the priority used by `resolveModelForLog` and `isGeminiRouted`: `payload.proxyModel ?? resolvedModel ?? payload.model`. also reverse-look up by `resolve`/`openRouterResolve` in `formatModelLabel` so a proxy target like "openrouter/anthropic/claude-opus-4.7" still renders as "Claude Opus". |
||
|
|
4101df566b |
router: decouple per-run key budget from wallet, add overdraft buffer (#616)
Replaces today's `keyLimitUsd = min(walletBalance, $25)` with population-aware buffers so users can use 100% of their credits before being paywalled, and opaque mid-run "more credits" failures (e.g. https://github.com/pullfrog/app/actions/runs/25531633203) get a clear PR comment instead of a generic stack-trace dump. Policy matrix: - Auto-reload accounts: `wallet + autoReloadAmountCents` (default $50, no cap) - Card + no-autoreload: `wallet + $5` overdraft buffer - No card: `wallet` (no buffer; existing zero-balance 402 stays) - OSS: `$10` (unchanged) Removes the $25 per-run cap entirely. Long Build runs at high-balance accounts no longer silently cap at $25. Other changes: - Classify mid-run OpenRouter "requires more credits, or fewer max_tokens" errors as `router_keylimit_exhausted` BillingError so users get an actionable PR comment. - Override OpenCode `max_tokens: 32000` default to `5000` via OpenCodeConfig.limit.output. Drops Opus per-call upfront budget reservation from ~$2.40 to ~$0.38 — what makes low-wallet runs viable at all. - Switch `findInitialComment` and `findExistingPaywallComment` to GraphQL `issueOrPullRequest(number:) { comments(last: 100) }` (single round trip, actually returns newest-100; REST listComments doesn't support sort/direction). Also fixes a latent `comments.find()` returning the OLDEST match instead of the most recent — now selects max(databaseId). - Wrap `syncAccountUsage` in `prisma.$transaction` with `SELECT ... FOR UPDATE` on the account row. Pre/post-balance reads inside the transaction enable deterministic low-balance edge detection (currently logs; will push the outreach.low_balance task once #592 lands). Plan: .cursor/plans/router-low-balance-paywall.plan.md (in companion wiki-billing branch) |
||
|
|
e4e93ea6d3 |
PR summary as agent-edited tmpfile snapshot (#568)
* PR summary as agent-edited tmpfile snapshot Replaces the comment-based PR summary path (and the in-progress update_pr_summary tool from #534) with a snapshot file the agent edits in place during Review / IncrementalReview / pr-summary Task runs. The server seeds the tmpfile with the previous snapshot (incremental) or a stable scaffold (first run), exposes the path via select_mode, and reads it back at end-of-run to persist to WorkflowRun.summarySnapshot and (when the prSummaryComment toggle is on) splice into the PR description body. Why a tmpfile rather than a tool call: incremental snapshot edits are output-token-cheap when the agent uses native file-editing tools, and range-diff cleanly across runs because section headings are stable. The agent never has to regurgitate the full snapshot to update it. Gating: snapshot generation is opt-in via either prSummaryComment="enabled" (splice into PR body) or prReReview="enabled" (snapshot feeds future incremental review runs as context). Users who disable both pay nothing end-to-end — no seeding, DB write, or body splice. Behavior changes: - Drop the Summarize mode and the Summary comment type entirely; the rolling summary is no longer a separate run shape. - pull_request_synchronize with re-review off and summary on still dispatches a silent pr-summary Task, but it edits the snapshot file instead of posting a fresh comment. - /api/repo/.../pr/.../summary-comment now returns { snapshot: string | null } from the DB instead of fetching a comment via GraphQL. URL kept stable so deployed older actions degrade gracefully. - summaryCommentNodeId is retained on WorkflowRun for legacy data and a future backfill of pre-snapshot comment-based summaries. Supersedes #534. The commit-tool/sub-agent direction in that PR is abandoned in favor of this file-based shape. * address review pass #1: synchronize fallback, splice idempotency, docs * address review pass #2: in-flight skip should not race summary fallback * address review pass #3: signal-handler flush, doc clarifications * address review pass #4: in-flight persist promise + bounded body-splice timeout * address review pass #5: defensive catch on persist worker, doc nit * add summary-stale post-run gate When generateSummary is set, we capture the bytes of the seeded snapshot file and pass them to the agent's post-run loop alongside the file path. After each agent attempt, the loop diffs the current file against the seed; if they're byte-identical the agent never touched it, and we nudge once via a resume turn (similar to the dirty-tree gate, but soft and fire-once so smaller models that legitimately decide no edit is warranted don't burn the retry budget). Mostly defends against forgetful smaller models on the Review path — their mode prompt asks them to edit the snapshot file, but the multi-step instruction can fall through when the diff is large. * trigger: retry vercel preview build * fix(action): drop unused re-export that pulled node:fs/promises into next bundle action/internal/index.ts was re-exporting DEFAULT_PR_SUMMARY_INSTRUCTIONS from action/utils/prSummary.ts, but nothing in the next.js app imports it. prSummary.ts uses node:fs/promises, and pullfrog/internal is aliased into the next bundle by next.config.ts, which made turbopack try to resolve node:fs/promises in client chunks and fail with: the chunking context (unknown) does not support external modules (request: node:fs/promises) drop the re-export — selectMode.ts (the only real consumer) already imports it directly from action/utils/prSummary.ts. * firewall PR summary snapshot from user instructions; resurrect rich format for Review The agent-internal snapshot (the markdown file the agent edits in place across runs) is exclusively durable context for future agent runs — user-supplied summarization instructions warp it and degrade that context. Drop the prSummaryCommentInstructions read path end-to-end: - handleWebhook: stop reading prSummaryCommentInstructions, stop passing prSummaryInstructions through dispatch options - action payload + ToolState + selectMode addendum: drop the instructions appendix; the snapshot prompt is fixed, not user-shaped - TriggersSettings: drop the InstructionsEditor for prSummaryCommentInstructions - prSummary.ts: reframe DEFAULT_PR_SUMMARY_INSTRUCTIONS as agent-targeted (durable context, not human-facing prose) Prisma columns (prSummaryComment, prSummaryCommentInstructions) and the matching zod schema entry stay for graceful retreat. Separately, resurrect PR_SUMMARY_FORMAT (deleted along with the Summarize mode in the original PR) and wire it into Review mode only. Initial PR reviews now include a structured summary section in the review body using the rich format (TL;DR, key changes, ## sections with before/after, file-link trails). IncrementalReview keeps its existing terser bullet-list shape since re-review bodies are deltas, not introductions. The user-facing review summary and the agent-internal snapshot are deliberately separate artifacts with separate prompts and zero shared content. * address review comments: prompt self-consistency + stale-doc cleanup PR 568 self-review (4232488109) flagged a self-contradiction the firewall commit introduced and three stale doc references that survived. - action/modes.ts: Review-mode step 2's trivial-PR shortcut said `submit "Reviewed — no issues found." per step 5`, but step 5's rewrite removed exactly that preamble. Aligned both: trivial PRs and no-actionable-issues PRs now produce a body that opens with "No new issues found." followed by the PR summary, so the user gets the headline up front and still sees what was reviewed. - docs/pr-reviews.mdx: dropped the "customize the summary style with Summary instructions in the console" sentence (the editor was removed in the firewall commit). Replaced with a note that the snapshot uses Pullfrog's built-in format and is not user-customizable. - wiki/prompt.md, wiki/modes.md: rewrote the snapshot-prompt entries to reflect the firewall — DEFAULT_PR_SUMMARY_INSTRUCTIONS is the entire prompt, prSummaryCommentInstructions is no longer wired in. * drop orphaned prSummaryCommentInstructions column Prod audit (455 repos): 5 non-null rows on a single account, all containing the literal placeholder text from the InstructionsEditor we removed in the firewall commit. No account has an intentional preference set, so silent-ignore (the keep-for-retreat option) costs us nothing meaningful while leaving an orphan column in the schema. Drop it. - prisma/schema.prisma: remove the column - prisma/migrations/20260506000000_drop_pr_summary_comment_instructions: ALTER TABLE ... DROP COLUMN - utils/schemas/triggers.ts: drop the matching zod entry * drop body splicing; snapshot is internal-only User-visible PR summarization continues to ship in Review and IncrementalReview review bodies (which already render PR_SUMMARY_FORMAT and "Reviewed changes" respectively). The snapshot tmpfile is now purely durable cross-run agent context — seed, edit-in-place, save to DB, feed the next run. Massive simplification: the body splice mechanics, the two-toggle gating matrix, the summaryHandlingCovered race tracking, and the synchronize summary-only Task fallback all go away. Code: - prSummary.ts: drop splice/strip/marker code (`splicePrSummary`, `stripExistingSummaryBlock`, `buildSummaryBlock`, `extractPrSummary`, PULLFROG_SUMMARY_START/END). keep scaffold, instructions, seed/read. - main.ts: rename persistAndPostSummary -> persistSummary; collapse to a single DB PATCH. drop pulls.get/pulls.update, drop AbortSignal timeout, drop in-flight promise machinery, drop prSummaryToBody plumbing. - ToolState: add summarySeed (replaces local var in main.ts so persist can compare). drop prSummaryToBody and summaryPersistInFlight. - persistSummary now compares against the seed and skips the DB write with a warning when unchanged — saving the seed verbatim is either a no-op or persists the placeholder scaffold, neither useful. - postRun.ts: when summary-stale is the only failing gate and the resume turn itself fails, restore the pre-resume successful result and break. symmetric with the existing reflection-failure preservation. summary-stale can no longer flip a successful run to failed. Webhook: - pull_request_opened: generateSummary follows prReReview only (the snapshot has no consumer when re-review is off). - pull_request_synchronize: collapses to "if prReReview enabled, dispatch IncrementalReview". the summaryHandlingCovered flag, the same-SHA/in-flight coordination it was protecting, and the summary-only Task fallback all delete cleanly. UI / config: - drop SummarizePRsTrigger (the toggle gated body splice; with that gone it has no behavior). drop sidebar entry, console import, Text icon import. - drop prSummaryComment from triggers zod schema, prisma schema, preview settings script. Migration: squash the two existing migrations into one timestamped 20260507000000_pr_summary_snapshot covering all three column changes (add summarySnapshot on workflow_runs, drop prSummaryCommentInstructions and prSummaryComment on repos). repo convention is one migration per PR. Action: bump 0.0.203 -> 0.0.205 (payload contract changed: prSummaryToBody removed; main is at 0.0.204). Out-of-diff cleanup: - review.ts:190 + review.test.ts:651 — "Reviewed — no issues found." -> "No new issues found." to match the canonical body in modes.ts. Verified: pnpm typecheck clean, pnpm lint clean, postRun + review tests pass, dev DB reset against production and the squashed migration applied cleanly (summarySnapshot present, prSummaryComment / prSummaryCommentInstructions both gone). * re-orient snapshot toward functional summary; drop prior-review-feedback section Empirical audit on preview-568 PR #5 showed the snapshot IS load-bearing for the orchestrator: lens-dispatch prompts on incremental runs carried forward context from the snapshot's risk register (e.g. "the JSDoc explicitly scopes to code points — do not flag grapheme-cluster issues" on the surrogate-pair fix run, "consistency with native padStart" on the padStart-added run). The orchestrator was reading the snapshot, reasoning about it, and using it to anti-prime / focus subagents — exactly the high-leverage path. My earlier "snapshot is write-only" claim was wrong. The shape, however, was steering it toward review-history-log instead of functional summary. This commit re-orients: - prSummary.ts: replace the four-section scaffold (~580 chars of placeholder italics under "What this PR does / Key changes / Risk / Reviewed in prior runs") with a minimal seed (~150 chars: just a header + a one-line comment about what the file is for). different PRs warrant different organization; forcing a refactor and a feature into the same template is procrustean. minimal seed also makes the unchanged-from-seed gate in persistSummary more sensitive. - selectMode.ts addendum: rewrite around three principles. (1) the snapshot is a FUNCTIONAL summary of what the PR does and the risks it carries, not a chronological review log — commit history can already be reconstructed from list_pull_request_reviews. (2) the orchestrator should USE the snapshot during triage and dispatch — concrete example given of carrying snapshot context into subagent lens prompts. (3) structure is the agent's call; stable headings make snapshots range-diff cleanly when they fit, but riff when they don't. - modes.ts IncrementalReview: drop the "Prior review feedback" checklist from the user-facing review body (step 6b gone, step 7 ELSE IFs cleaned up). It duplicated content that's already covered by the Reviewed-changes bullets and tracked durably in the snapshot for the next agent run; in the user-facing body it was noise. step 3 still fetches prior reviews but its role is now just filtering aggregation in step 5, not rendering. - AGENTS.md: codify "no follow-ups" rule. when an issue is identified during code review, fix it in this PR — PR scope does not constrain quality. follow-up TODOs are forbidden as a substitute for doing the work now. Empirical evidence supporting the re-orientation: - Run 25568912293 (PR#5 incr1, surrogate-pair fix): orchestrator's correctness lens dispatch said "Do NOT flag grapheme-cluster issues — the JSDoc scopes to code points." The grapheme-cluster framing was not in the diff; it was downstream of the snapshot's prior risk-section framing of truncate's contract. Snapshot influencing dispatch. - Run 25569054779 (PR#5 incr2, padStart added): orchestrator's correctness lens dispatch enumerated edge cases including "consistency with native String.prototype.padStart contract" and "fill = multi-code-point string (e.g. emoji)". Both threads carried over from the snapshot's prior truncate code-point-vs-code-unit discussion. Snapshot informing the shape of what was looked for. The cost of maintaining the snapshot (~800 tokens, ~$0.005/run) is trivially affordable when it materially improves orchestrator triage on the 1-5 lenses dispatched per review. |
||
|
|
ae8a634450 |
action: quieter, deep-linked billing error comments (#600)
* action: quieter, deep-linked billing error comments The PR progress comment for billing errors led with a loud `### ❌ Pullfrog billing error` H3 and pointed at the bare `/console` index page regardless of which org owned the repo. Make the copy quieter and more actionable: - bold first line instead of an H3 (the comment already has Pullfrog branding in the footer, no need for a second header) - thread `runContext.repo.owner` into the formatters and deep-link to `pullfrog.com/console/<owner>#billing` (or `#model-access` for the router-needs-card branch) - split the old "insufficient balance" default into two branches: card declined (Stripe returned a declineCode — "we'll retry next run") vs. balance empty (no in-flight charge — "top up or enable auto-reload") - strip UX framing and pullfrog.com URLs from the proxy-token 402 responses; they're now terse signal-only strings, with all copy and links rendered by the action so there's a single source of truth * proxy-token: return 503 on phase-1 txn failure, not 402 Phase-1 only fails on server-side issues (serializable retry exhaustion, Prisma/DB flake) — no Stripe call has happened yet, so it's not a billing decline. Pre-PR this rendered as the generic "billing error — manage billing" copy, which was vague-but-not-wrong; under the new copy it would falsely tell the user their balance is empty. Returning 503 routes the action through TransientError ("temporarily unavailable, retry") which is the accurate framing. Caught by Pullfrog review on PR #600. |
||
|
|
f87e0f878c |
action: minimize pullfrog.yml permissions and drop actions:read (#594)
* action: minimize pullfrog.yml permissions and drop actions:read
The recommended pullfrog.yml workflow asked for a permissions block that's
broader than what the action actually uses with the workflow GITHUB_TOKEN —
all real work (git push, PR comments, reviews) goes through installation
tokens that the action mints via OIDC. Customer security scanners flagged
the workflow-level block as too permissive.
- Move permissions to the job level and reduce to id-token: write,
pull-requests: write, issues: write. contents:read is the implicit default
and covers actions/checkout; contents:write, checks:read are unused by
any GITHUB_TOKEN consumer; actions:read was only used by post-cleanup's
listJobsForWorkflowRun call.
- Replace listJobsForWorkflowRun with a SIGTERM/SIGINT handler in main.ts
that calls core.saveState("cancelled", "true"); post-cleanup reads it
back via core.getState. Same cancel-vs-failure UX, no extra scope needed.
- Sync the docs (headless-action, getting-started, action/README) and the
two dogfood pullfrog.yml workflows to the new minimal block. Update the
post-cleanup wiki to describe the saveState approach.
* action: drop pull-requests/issues from required workflow scopes
Switch postCleanup.ts to mint its own short-lived installation token via OIDC
(acquireNewToken with issues:write + pull_requests:write) instead of using the
workflow GITHUB_TOKEN. Same comment-update behavior, but the workflow no longer
needs those scopes — the only permissions Pullfrog ever asks for are id-token:write
(OIDC exchange) and contents:read (actions/checkout).
Also fixes a bug from the previous commit: setting an explicit permissions block
drops every unlisted scope to none (with metadata as the only exception), so
omitting contents would have broken actions/checkout. Restored at both workflow
and job level.
* action: scope id-token:write to pullfrog job, not workflow level
id-token:write is the powerful one — it lets a job mint OIDC tokens that can
be exchanged for cloud credentials or our installation tokens. Keeping it at
workflow level means any future job added to this file silently inherits it.
Move it to the job level where it's actually used; leave only contents:read
at workflow level as a safe baseline for any future jobs.
* action: move stuck-comment cleanup server-side, drop write perms entirely
The action's post-cleanup step lived inside the runner and used the workflow
GITHUB_TOKEN to update the "Leaping into action…" progress comment when a run
failed/cancelled, requiring pull-requests:write + issues:write at the workflow
level. Move that responsibility to the workflow_run.completed webhook handler:
it already has installation-token access via the GitHub App, runs server-side
(no Pullfrog API dependency loop on failure), and lets us drop both write perms.
Recommended workflow permissions block is now truly minimal:
permissions:
contents: read
jobs:
pullfrog:
permissions:
id-token: write
contents: read
Server side
- handleWorkflowRunCompleted: when conclusion != "success" and the WorkflowRun
has progressCommentId, mint installation octokit and update the stuck comment
in place. Try issues.getComment first, fall back to pulls.getReviewComment on
404 (we don't store comment type — one wasted GET on the rarer review case).
- Reuses buildPullfrogFooter and updateProgressComment from pullfrog/internal,
matching the wording the action used to write client-side.
Client side
- Delete action/utils/postCleanup.ts and action/post.ts.
- Remove post: + post-if: from action/action.yml.
- Drop runPostCleanup wiring from action/commands/gha.ts and action/play.ts.
- Remove the SIGTERM/saveState handler I added in main.ts in the previous commit
(no longer needed; cancel/fail signal comes from the webhook hook payload).
Plumbing
- Extract isLeapingIntoActionCommentBody into action/utils/leapingComment.ts so
the predicate can be re-exported via pullfrog/internal without dragging the
MCP server's transitive type graph into the Next.js app's typecheck.
- mcp/comment.ts re-exports from the new location for backward compat.
Wiki
- Delete wiki/post-cleanup.md (obsolete; cleanup is now a one-liner branch in
the workflow_run webhook handler).
* chore: ignore .worktrees in biome config
Recently-added pnpm worktree feature creates nested git worktrees under
.worktrees/, each with their own biome.jsonc declaring root. Biome's
recursive scan trips on the nested config and fails pnpm lint. Excluding
the directory matches the existing .gitignore entry.
* fix: address PR #594 review findings
Two real bugs caught by code review:
1. handleWorkflowRunWebhook.ts:323 — drop the /m flag on the stuck-comment
detection regex. With /m, ^ matches any line start, so any finalized
progress comment that embeds a task list (report_progress writes
`- [x]`/`- [ ]` lines via todoTracking.ts) would be flagged as "stuck"
and silently overwritten with the "This run croaked" boilerplate
whenever the workflow concluded non-success after the agent's final
summary already landed. Restores the body-start anchoring the original
in-process postCleanup.ts:90 had.
2. action/scripts/check-entrypoint-imports.ts — drop ../post.ts from the
esbuild entry-point list (the file was deleted in aa43b9af). The
`pnpm check:entrypoints` step in test.yml would have failed on every
run with an unresolvable-entry-point error.
Plus three small follow-ups:
- main.ts:580 — comment said "post-cleanup has its own verify-retry loop"
but post-cleanup is gone. Updated to describe the new server-side path.
- mcp/comment.ts:443 — comment said "so post script doesn't think the run
failed". Updated to describe the actual current consumers of wasUpdated.
- commands/gha.ts:84 — `--post` help text said "run post-cleanup flow" but
with the post-cleanup path removed, --post is only valid alongside the
`token` subcommand for installation-token revocation. Updated wording.
* fix(action): scope --post help text to gha token subcommand
Root gha help text was documenting --post, but --post only makes sense
paired with the token subcommand (it's how the post step revokes the
installation token previously acquired in the main step). Move it to a
dedicated gha token help section and add a parser layer that rejects
--post on the bare gha command.
$ pullfrog gha --help
usage: pullfrog gha [subcommand]
...
options:
-h, --help show help
$ pullfrog gha token --help
usage: pullfrog gha token [--post]
...
options:
-h, --help show help
--post revoke the previously-acquired token (post-step usage only)
* webhook: artifact-aware cleanup of stranded leaping comments on success
Previously the workflow_run.completed cleanup only handled non-success
conclusions. Extend it to also catch the rare case where a successful
run leaves a "Leaping into action…" comment stuck (in-process cleanup at
action/main.ts:723 normally handles this, but can be skipped on SIGKILL,
runner host crash, or any exit path that bypasses main()'s finally block).
New behavior in cleanupStuckProgressComment:
- cancelled → update with "cancelled 🛑" body (unchanged)
- failure (other) → update with "croaked 😵" body (unchanged)
- success + artifact recorded → delete the comment (the artifact is the
user-facing surface; the leaping comment
is just stale UI noise at this point)
- success + no artifact recorded → delete the comment AND alert
team@pullfrog.com via emailAlert
The "success + no artifact" path is "should never happen" territory: the
run claims success but produced no review, PR, issue, plan, or summary
comment. The team alert helps us catch in-process cleanup regressions or
artifact-tracking gaps. hasRecordedArtifact reads {review,pr,issue,
planComment,summaryComment}NodeId off the WorkflowRun row to make the call.
* webhook: narrow stuck-comment detection to leaping prefix only
Drop the stranded-todo-pattern branch from cleanupStuckProgressComment.
The leaping prefix is highly specific and impossible to confuse with a
legitimate summary; a leading todo line is not — the agent's
error-reporting paths can produce useful explanatory comments whose
body leads with a checklist (e.g. "here's what I was working on" + the
incomplete todo list), and we don't want to silently overwrite those
with the generic "croaked" boilerplate.
In-process cleanup at action/main.ts:723 still handles the stranded-todo
case in the common path (gated on !finalSummaryWritten with full access
to the in-memory tool state). Missing the rare runner-died-mid-todo case
server-side is a worthwhile trade vs. the false-positive risk on real
explanatory comments.
|
||
|
|
4c1413d925 |
fix(action): flip wasUpdated on substantive MCP write tools (#563) (#577)
* fix(action): flip wasUpdated on substantive MCP write tools (#563) Review/Respond/etc. agents that submit a `create_pull_request_review`, `create_issue_comment`, or `update_pull_request_body` and exit without calling `report_progress` were being marked as workflow failures by the strict completion check in handleAgentResult. Extend the set of tools that flip toolState.wasUpdated so a substantive user-visible artifact satisfies the check. The isReviewMode bypass is retained for IncrementalReview's non-substantive path. Flag is set BEFORE patchWorkflowRunFields / deleteProgressComment in each tool so a best-effort cleanup failure does not undo the signal. * fix(action): use finalSummaryWritten for stranded progress cleanup The stranded-progress-comment cleanup at the end of main() previously fired only when toolState.wasUpdated was false (or the tracker was the last writer). With wasUpdated now set by additional MCP write tools (create_issue_comment, update_pull_request_body), an agent that produced a substantive artifact via one of those tools and skipped report_progress would leave the placeholder "Leaping into action" comment intact — the post-script then converted it into an error message on a successful run. Key the cleanup off finalSummaryWritten instead. That flag is only set when report_progress actually wrote the progress comment, so it cleanly distinguishes "comment is finalized" from "agent did other work but never touched the progress comment". * refactor(mcp): extract markSubstantiveArtifact() helper replaces 4 inline `ctx.toolState.wasUpdated = true` flips in CreateCommentTool, UpdatePullRequestBodyTool, and CreatePullRequestReviewTool with a single helper in mcp/server.ts. JSDoc on the helper documents the contract (call BEFORE downstream patch/cleanup; gates the strict completion check and stranded-comment cleanup) so future MCP write tool authors only need to grep for one symbol. no behavioral change. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): only flip finalSummaryWritten after non-skipped write Previously the flag was set unconditionally on any non-plan call, including paths where reportProgress skipped (silent events, deleted comment, no issue/PR target). The cleanup check in main.ts is safeguarded by toolState.progressComment so the bug doesn't manifest today, but aligning the flag with actual writes matches the wasUpdated pattern and the design intent in the cleanup plan. * refactor(mcp): inline markSubstantiveArtifact helper --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: David Blass <david@arktype.io> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
560e27bda5 |
refactor progress comments into a bundled type + helper module (#567)
* refactor progress comments into a single bundled type + helper module
introduce ProgressComment ({ id, type: "issue" | "review" }) as the canonical handle for
the GitHub comment a run uses to report progress, and route every read/update/delete/create
through a single helper module (action/utils/progressComment.ts). previously every site that
touched the progress comment hardcoded octokit.rest.issues.*Comment, which made adding a
second comment type (review-thread replies) require duplicating the same branch in 6+ places
— the same shape that bit pullfrog/app#445.
new capability: when the address-reviews trigger fires for a one-off review comment, the
"Leaping into action" comment is now posted as a reply in that review thread instead of as
a top-level PR timeline comment. the helper handles failure (e.g. parent comment deleted)
by silently falling back to a top-level issue comment, so the run never loses its progress
surface.
changes:
- action/utils/progressComment.ts (new) — ProgressComment type + getProgressComment,
updateProgressComment, deleteProgressCommentApi, createLeapingProgressComment. uses a
structural Octokit interface to bridge the @octokit/rest version mismatch between the
action package (v22) and the root project (v21).
- action/internal/index.ts — re-export the new types and helpers for cross-boundary use.
- action/external.ts, action/utils/payload.ts — replace progressCommentId: string with
progressComment: { id: string, type: "issue" | "review" } in WriteablePayload + JsonPayload.
wire-format breaking, no legacy fallback (in-flight runs across the deploy lose their
progress comment, fine).
- action/mcp/server.ts — ToolState.progressCommentId becomes
progressComment: ProgressComment | null | undefined (same tristate semantics).
- action/main.ts, action/mcp/comment.ts, action/utils/errorReport.ts,
action/utils/postCleanup.ts — every issues.*Comment call against the progress comment
routes through the helper module. zero hardcoded API branching outside the helper.
- utils/github/triggerWorkflow.ts — drop createLeapingComment + updateCommentToLeaping;
dispatchAndTrackWorkflow gains a resolution chain (existingComment → replyToReviewComment
→ triggeringIssue → none) and an existingComment: ProgressComment param plus
replyToReviewComment: { pullNumber, commentId }.
- utils/webhooks/handleWebhook.ts — dispatch closure threads replyToReviewComment through;
the one-off review comment branch passes it and skips the now-redundant eyes reaction
on the comment we're about to reply to.
- app/trigger/[owner]/[repo]/[number]/page.tsx, utils/github/runActionLocal.ts,
app/api/cli/dispatch/route.ts, app/api/dispatch-workflow/route.ts — call sites updated to
new shape.
no schema or DB column changes. the existing WorkflowRun.progressCommentId column is still
written by id only; type lives only on the in-flight payload, which is sufficient for
runtime since it's the only thing that needs to know which API to call.
* anneal pass 1: fallback visibility + stale doc/comment updates
- progressComment.ts: when reviewReply→issue fallback fires, prepend a [!NOTE] callout
with a permalink back to the original review comment. without this, the parent comment
showed no eyes reaction (deliberately skipped) and no reply, leaving the user with no
signal that anything happened.
- wiki/post-cleanup.md: update progressCommentId references to progressComment, document
the new helper-based dispatch by type.
- wiki/main.md: update initToolState({ progressCommentId }) → ({ progressComment })
in the resolver-chain diagram.
- action/main.ts, action/mcp/review.ts: update two stale comments that referenced the
old field name.
* anneal pass 2: post-cleanup detection through fallback notice + log cleanup
- isLeapingIntoActionCommentBody: strip a leading GFM blockquote/alert before
testing the leaping prefix. without this, the [!NOTE] callout that the
reviewReply→issue fallback prepends would prevent post-cleanup from
recognizing the stuck "Leaping into action..." comment, leaving it permanently
on the PR timeline if the workflow died before any progress update.
- progressComment helper: switch from log.warning (action-flavored, emits a
::warning:: GitHub Actions annotation) to console.warn so the helper doesn't
pollute Vercel logs when invoked from the webhook context.
- triggerWorkflow.ts: drop the duplicate caller-side log on review-reply
failure — the helper already speaks loudly. Reword the catch-branch log to
reflect that it now only fires when both the reply AND the helper's internal
fallback failed.
- progressComment.ts: document that the [!NOTE] fallback notice is overwritten
on the first report_progress call, and explain the trade-off vs persisting
it through the action payload + ToolState.
* debloat: drop the [!NOTE] fallback callout
Reverting two pieces from the prior anneal pass:
- progressComment.ts: drop the [!NOTE] callout that the reviewReply→issue fallback
prepended to the leaping body. It disappeared on the agent's first report_progress
call, which made it half-committed to visibility — worse than either properly
persisting it (real engineering) or leaving the fallback silent (current choice).
The console.warn diagnostic and the workflow-run footer link in the leaping
comment itself give us enough signal for the rare case where both API endpoints
fail at once.
- isLeapingIntoActionCommentBody: revert the leading-blockquote stripping; only
needed to compensate for the [!NOTE] callout.
Keeping: the console.warn-vs-log.warning fix (real cross-runtime concern), the
duplicate-log drop in triggerWorkflow.ts, the wiki updates, and the two stale
source-comment fixes.
* fix: prevent stranded task list overwriting post-cleanup message
When a run is cancelled, the action's todoTracker may have an HTTP write in
flight to GitHub when SIGTERM lands. The action process dies, but the request
data has already left the socket — GitHub processes it and updates the comment
body to the (stale) task list. Meanwhile post-cleanup, running in a separate
process, writes the "This run was cancelled 🛑" message. If the tracker's
in-flight write happens to land *after* post-cleanup's write, the user never
sees the cancellation message.
Two-layer fix:
- Action side: cancel the tracker in the SIGTERM signal handler so no new
debounced writes get scheduled. This shrinks the race window but can't
un-send a request already on the wire.
- Post-cleanup side: after writing, verify the body landed and re-issue if
another write clobbered ours. Loops up to 3× with a 3s settle delay so
delayed in-flight writes from the dying action have time to arrive before
our read-back check decides whether to retry.
* lint: import createLeapingProgressComment from pullfrog/internal in test script
* address bot review findings: reply-target root, version bump, GET error handling
Three real findings from the bot reviews on #567 plus a small DRY pass:
1. handleWebhook reply-target: `newComments[0]` may be a reply, not a
top-level review comment. `getReviewCommentsWithReplies` returns root +
replies for any thread the review touched, and `pull_request_review_id`
filtering only narrows by *which review submitted*, not *root vs reply*.
When a user submits a single reply as their entire review (e.g. replying
to someone else's comment to ping @pullfrog), the reply ID flowed through
to `createReplyForReviewComment`, which 422s on replies-to-replies and
degraded to a top-level issue comment — exactly the polluted-PR-timeline
behavior this PR was built to remove. Walk up `in_reply_to` from the
already-fetched thread data to find the root and reply there instead.
2. action/package.json: bumped 0.0.202 → 0.0.204. main is at 0.0.203 and
our wire format changed; without a bump validateCompatibility can't
surface the mismatch on the deploy boundary, and the merge would have
gone backwards.
3. postCleanup writeAndVerify: distinguish a thrown verify-GET from a
"body got overwritten" mismatch. Treating a transient 5xx/rate-limit GET
the same as a clobber wasted PUT attempts and printed a misleading
"in-flight writes kept clobbering us" warning. We trust our PUT (which
returned 200) and exit instead of amplifying writes against a flaky API.
4. Small DRY: extracted parseProgressComment for the
`{ id: string; type } -> ProgressComment` parse that had drifted across
server.ts and postCleanup.ts.
|
||
|
|
e58299740d |
Merge pull request #545 from pullfrog/billing
managed billing + stripe v1 |
||
|
|
8cee07d388 |
move progress-comment cleanup into create_pull_request_review (#551)
* fix: snapshot review state so progress comment cleanup actually fires postReviewCleanup deletes toolState.review as its second statement, so the defense-in-depth `if (toolState.review && progressCommentId)` branch right after never saw a truthy value. This left an orphaned progress comment alongside the submitted review whenever the agent called report_progress despite Review/IncrementalReview mode instructions (seen in the wild on colinhacks/zod#5767). Snapshot the boolean before postReviewCleanup runs. * move progress-comment cleanup into create_pull_request_review The previous commit snapshotted toolState.review to work around postReviewCleanup deleting it before the cleanup branch could read it. That fixed the symptom but kept a fragile design: the rule "review submitted → progress comment is noise" was enforced from the bottom of main.ts via a flag set in one place and consumed in another, with a helper between them that mutated the same flag for unrelated reasons. Move the rule to its natural owner. create_pull_request_review now calls deleteProgressComment immediately after the review is persisted, so the cleanup is atomic with submission. This: - closes the catch-block hole — a review submitted right before a timeout/crash now still cleans up its progress comment. - removes the dead "defense-in-depth" branch in main.ts that was the original bug surface. - relies on the existing progressCommentId=null no-op path in reportProgress to make any later report_progress call a no-op (so the misbehavior path can't re-create the orphan). - only fires for Review/IncrementalReview in practice — those are the only modes that call create_pull_request_review, and both are prompted not to call report_progress. Build/AddressReviews/Plan never reach this code path, so their progress comments remain untouched. Stranded-comment cleanup in main.ts is unchanged and still handles the truly orphaned case (no review, no report_progress). |
||
|
|
c6a757424c |
Stop hook + learnings reflection via post-run loop (#515) (#548)
* add stop hook + learnings reflection to post-run loop (#515) stop hook (#515): repo-configured script that runs after the agent finishes. non-zero exit resumes the agent with the hook output as guidance; persistent failure (3 attempts) marks the run failed. the dirty-tree and stop-hook gates share a single retry loop so a fix + push happen in one turn. learnings reflection: per Colin, the learnings step baked into mode checklists rarely fires — the agent stays focused on the task and the meta-ask falls through. the post-run loop now delivers a dedicated one-shot --continue turn asking the agent to call update_learnings if relevant, nothing else competing for attention. reflection doesn't consume the gate-retry budget; if it dirties the tree, the next loop iteration catches it via the dirty-tree gate. plumbing: Repo.stopScript column + migration, zod schema, run-context api, AgentSettings UI. RepoSettings.stopScript threads through to AgentRunContext and into each agent harness. subprocess-dependent logic lives in action/agents/postRun.ts to keep action/agents/shared.ts lean — shared.ts is reachable from pullfrog/internal, and pulling node:child_process through it leaks into root tsc (which uses bundler resolution, not NodeNext). * fix: preserve successful run when reflection turn fails The post-run reflection turn (update_learnings nudge) is a best-effort one-shot; its failure must not flip a successful run to failed. Prior code overwrote `result` with the reflection's return value, so a model API error during reflection caused the whole run to be reported as failed even though the gated work had already completed cleanly. Now: save the pre-reflection result, and if reflection returns `success: false`, log a warning, restore the prior success, and exit without re-invoking the gates (re-running a freshly-green stop hook risks a flaky false-positive failure). Adds action/agents/postRun.test.ts covering the reflection path — previously uncovered. * fix: surface both stop-hook stdout and stderr to the agent The `(stderr || stdout)` heuristic in executeStopHook dropped stdout entirely whenever stderr had any content. Scripts that emit a benign warning to stderr and the actionable error to stdout (common for wrapper scripts) starved the agent of the information it needed to fix the issue. Now concatenate both streams (stderr first, stdout second, skipping empty ones) before truncation. This keeps stdout's tail — usually where summaries and totals live — intact under the 4096-char cap. * test: lock in the core post-run retry + reflection invariants PR #548's test plan ships four manual verification scenarios. Convert three to vitest coverage, catching regressions on the hottest code paths: - persistent stop hook failure exhausts MAX_POST_RUN_RETRIES and surfaces as AgentResult.error with both the retry count and the verbatim hook output (so the GitHub-comment rendering stays actionable). - every gate retry is fed the hook output as the resume prompt. - usage aggregates across the initial run plus every retry (billing relies on this). - reflection turn still fires when no stop hook is configured and the tree is clean. Manual item remaining is the full UI round-trip of the settings form, which is out of scope for unit tests. * test: cover executeStopHook soft-fail and truncation invariants Three paths the PR documents but previously had no regression gates: - timeout (SPAWN_TIMEOUT_CODE) and activity-timeout (SPAWN_ACTIVITY_TIMEOUT_CODE) must return null, not a failure. a hook that times out is an infra problem; retrying with an agent turn risks an infinite loop. - spawn errors (ENOENT from a typoed binary, etc.) take the same soft-fail path for the same reason. - oversize hook output is truncated to the last 4096 chars with a "truncated" marker, keeping the tail (where summaries live) and protecting the 65535-char GitHub-comment budget downstream. Regression targets — a refactor that accidentally surfaces an infra failure as a gate failure, or blows the comment budget, will now fail loudly in CI. * test: cover soft-fail, no-resume, and short-circuit invariants Three more documented behaviors that previously had no regression gates: - dirty-tree-only is a soft-fail: persistent uncommitted changes log and warn but DO NOT flip the run to failed. a regression that started surfacing this as AgentResult.error would break every run that leaves a test fixture untracked. - canResume=false + stop hook failure still surfaces the hook failure as AgentResult.error. the retry budget is zero so "N retry attempts" is correctly omitted from the message, but the run still reports WHY it failed rather than silently reporting success. - initial result with success=false short-circuits the loop: no gate checks, no reflection, no resume calls. the original agent error flows through verbatim for clean triage. Also reset mockedSpawn in beforeEach so test state doesn't leak between cases. * test: lock in the reflection-dirties-tree → dirty-tree-gate path The PR description claims: "if the reflection turn dirties the tree, the loop picks that up on the next iteration via the normal dirty-tree gate." There was no regression gate on this invariant. Without it, a refactor that moved the reflection out of the retry loop (e.g., into a one-shot post-loop call) would silently bypass the commit-before-you-finish contract whenever the agent misbehaves during reflection — uncommitted changes would ship as part of the run's "success" state. The test sequences three getGitStatus returns (clean → dirty → clean) and asserts two resume calls: REFLECTION first, then UNCOMMITTED CHANGES with the dirtying file in the prompt. * fix: preserve pre-reflection task output when reflection succeeds the reflection turn's reply ("done" or "updated learnings with N bullets") is a meta-ask, not a task summary. before this fix, result = reflectionResult clobbered the original task's output on the returned AgentResult, so downstream consumers (handleAgentResult's fallback path when toolState is empty, programmatic callers of main()) saw the reflection's trivial reply instead of the real summary. spread reflectionResult to inherit fields subsequent gate retries need (e.g. the new sessionId claude emits per --resume invocation), but keep the pre-reflection output verbatim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: fall back to reflection's output when pre-reflection output is empty the prior fix used `??` which only fell through on null/undefined. runs that communicate exclusively through MCP tools (e.g. report_progress) and emit no plain text leave result.output = "", which `??` preserved as-is — dropping the reflection's reply and leaving handleAgentResult's fallback path with nothing to show. switch to `||` so empty-string pre-reflection output yields the reflection's output instead of ""; non-empty task output still wins as intended. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: drop reflection-failure-skips-hook test (over-specified control flow) the test pinned the literal `break` in the post-reflection failure branch with stopScript=null, asserting only that getGitStatus was called once. that's not a behavior contract — a reasonable refactor (e.g. `continue` to re-check gates with explicit flake guards) would fail this test even though the new behavior would be fine. the "does not flip a successful run to failed" test already covers the only thing callers depend on. * test: drop low-value mock-driven tests from postRun - "fires the reflection turn when no stop hook is configured" — fully subsumed by the output-preservation test (asserts task output survives, which is only possible if reflection fired). - "uses stdout alone" / "uses stderr alone" — pin format trivia (`filter(Boolean).join`) that LLMs ignore. - "returns empty output (not undefined) when both streams are empty" — guards a TS-impossible case; every consumer uses `output || "(no output)"`. - "returns null on activity-timeout" — duplicate of the timeout test; same `return null` branch with a different constant. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
f662b1a0c8 |
unify per-run token + cost accounting + persist to WorkflowRun (#547)
* unify per-run token + cost accounting across agents every agent harness now logs the same 5-column (or 6 with cost) table and populates the same AgentUsage contract, regardless of agent or upstream provider. previously OpenCode and the Claude fallback path emitted a 3-col table whose "Input Tokens" was actually only the non-cached delta, silently dropping cache read/write — real runs were being reported at ~0.4% of their true input (e.g. one baseline showed Input=30 while step_finish events summed to cache_read=724,753). changes: - add logTokenTable helper in action/agents/shared.ts with stable columns: Input | Cache Read | Cache Write | Output | Total | Cost ($). cost column renders only when a value is known. - action/agents/opencode.ts: accumulate step_finish.part.tokens AND step_finish.part.cost (sourced from models.dev inside opencode — confirmed working across Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, and OpenRouter). drop the event.stats.total_tokens fallback since that payload has no cache breakdown. - action/agents/claude.ts: success-path now treats input_tokens as the non-cached field (matching OpenCode semantics), carries cache_read_input_tokens / cache_creation_input_tokens separately, and captures total_cost_usd from the final result event. the per-message fallback accumulator now captures cache fields too so it's no longer lossy when the result event never fires. - formatUsageSummary gains a Cost ($) column that matches the stdout table row-for-row; missing values render as "—". - scripts/token-usage.ts parses all three historical formats (new 5-col, legacy 4-col Claude success, legacy 3-col lossy) and explicitly flags the lossy runs instead of averaging misleading values. validation (pnpm play --local, identical "say hello" prompt): agent+model Input CacheR CacheW Output Total Cost OpenCode + Anthropic Sonnet 4.6 4 41,177 20,735 129 62,045 $0.0921 Claude CLI + Anthropic Sonnet 4.6 9 80,133 11,611 389 92,142 $0.0766 OpenCode + OpenAI codex-mini 10,893 46,976 0 606 58,475 $0.0059 OpenCode + Google Gemini 3 Flash — — — — — $0.0114 OpenCode + xAI Grok 4 Fast — — — — — $0.0035 OpenCode + DeepSeek Chat 18,854 0 0 1 18,855 $0.0053 OpenCode + Moonshot Kimi K2.5 — — — — — $0.0106 OpenCode + OpenRouter→Anthropic — — — — — $0.0617 OpenCode + OpenRouter→OpenAI — — — — — $0.0038 * isolate play.ts from developer gitconfig play.ts is a CI-emulator but inherits the developer's user- and system-scope gitconfig. a common local convenience — url."git@github.com:".insteadOf "https://github.com/" to force SSH auth — gets applied at read time on every git call inside the temp repo, causing `git remote get-url --push origin` to return an SSH URL instead of the stored HTTPS one. pullfrog_push_branch's validatePushDestination (correctly) treats that as tampering and blocks the push. the agent then burns the full MAX_COMMIT_RETRIES budget trying workarounds that can't beat a user-scope insteadOf rule, turning a trivial "say hello" run into a 1.35M-token session. point GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at /dev/null inside run() so the play process and its spawned agent see the same empty gitconfig that a real CI runner would. CI has no rewrites, so this is a no-op there; dev machines get CI-identical git state. SSH client config (~/.ssh/config and keys) is separate from gitconfig and is unaffected, so setupTestRepo's SSH clone still works locally. setupGit only writes --local scope, so nothing downstream depends on user-scope values. verification: with the scratch repo cleaned up and this isolation in place, OpenCode + Anthropic on the same "say hello" prompt goes from 1,349,654 tokens / $2.00+ to 62,045 tokens / $0.0921 — no retry loop, no push blocks. * persist aggregated token + cost usage to WorkflowRun AgentUsage has been memory-only — rendered into the GitHub step summary and then discarded when the runner tears down. that made questions like "avg cost per customer per day" require log-spelunking. persist it: - add Int? columns for inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens and a Decimal? costUsd column on workflow_runs. Int4's 2.1B ceiling is ~200x larger than any realistic run so BigInt would be overkill. costUsd uses the same default Decimal precision as existing money columns (accounts.usageUsd, proxy_keys.hwmUsage). - extend PATCH /api/workflow-run/[runId] to accept the new numeric fields alongside the existing artifact strings. per-field type validation ensures the allowlist stays scalar-safe and rejects negative / non-finite values. - generalize patchWorkflowRunFields in the action so it accepts a mixed string/number payload, and add an aggregateUsage(entries) helper that sums per-agent AgentUsage records into a single patch. - call the reporter from main.ts's outer finally block, gated on toolContext. this is the shared cleanup path that every agent implementation flows through — claude.ts, opencode.ts, and any future harness all push their AgentUsage into toolState.usageEntries via the same line 468, so one finally-block call covers them all. running in finally also means partial usage gets persisted even when the agent errored out mid-run. * anneal token + cost accounting follow-up polish from a review pass: - aggregate usage across commit-retry iterations inside each agent harness. previously runClaude / runOpenCode returned only the final retry's usage, so any run that hit the dirty-tree retry loop under-counted tokens and cost in both the stdout table and the WorkflowRun row. added a shared mergeAgentUsage helper in agents/shared.ts; both harnesses now fold each iteration's usage into a running total and return the sum. - scripts/token-usage.ts now handles the unified format with or without the Cost ($) column. previously the int-only number regex rejected decimals and the 5-cell length check rejected 6-cell rows, so logs from post-cost-tracking runs fell through to "no token table". the parser now accepts both 5- and 6-cell unified rows, splits int vs decimal cells, and averages reported Cost alongside the tokens. - PATCH /api/workflow-run/[runId] now rejects INT field values above INT4_MAX (2_147_483_647) so a malformed payload gets a clean 400 instead of propagating a Prisma error. also defends against a compromised runner sending a deliberately huge value. - clarifying comments: opencode.ts documents that step_finish.part.cost is a per-step delta (empirically verified), main.ts explains that toolState.usageEntries already carries merged per-retry usage so aggregateUsage just sums entries (one per agent.run()). - tests for aggregateUsage and mergeAgentUsage — 12 new cases covering empty / partial / multi-agent inputs and the "keep undefined" semantic that prevents spurious zeros from being persisted. - drop `as number` cast in logTokenTable — narrow via const instead. * anneal: clamp INT overflow + guarantee mergeAgentUsage immutability second review pass surfaced two defensive gaps: - a single token field exceeding INT4_MAX would pass the client but be rejected by the server's per-field validator, writing a partial row with some NULLs where sums belonged. clamp in aggregateUsage so the wire payload is always self-consistent across all numeric columns, with a loud warning so the clamp doesn't silently swallow weirdness. - mergeAgentUsage's single-sided branches returned the input reference. callers treat AgentUsage as immutable but future callers might not; always return a fresh shallow copy instead. two new tests guarantee the no-mutation-leak property. no behavior change in the happy path — INT4_MAX is ~200x the largest realistic per-run token count. * anneal: resilient usage persistence + cross-platform null device third review pass surfaced three small issues: - main.ts finally block: writeGitHubUsageSummaryToFile throwing would skip the WorkflowRun usage PATCH. both are independent best-effort cleanup tasks — wrap the former in catch so a filesystem failure doesn't block DB persistence. - AgentUsage.inputTokens had no jsdoc explaining that it's the full billable input (cached + non-cached). the same word "Input" means "non-cached only" in the stdout/markdown tables (derived by subtraction). document the semantic so dashboards querying WorkflowRun.inputTokens don't misinterpret it. - play.ts gitconfig isolation was hard-coded to "/dev/null" which doesn't exist on Windows. use `os.devNull` for cross-platform parity (resolves to `\\.\nul` on win32). the project is Linux-only in CI so this only helps local Windows contributors, but it's a zero-cost swap. also updated the finally-block caveat comment: usage is only pushed to toolState.usageEntries when agent.run() returns an AgentResult, not when the timeout race rejects — so timed-out runs don't persist partial usage. documented instead of trying to thread state through Promise.race. * anneal: NaN-guard cost accumulators + clarify inputTokens docs final polish from review round 4: - guard both cost accumulators (opencode step_finish.part.cost and claude result.total_cost_usd) with Number.isFinite. `typeof x === "number"` accepts NaN, and one NaN `+=` would poison the running total for the whole session. - reword prisma schema comment on WorkflowRun usage fields to call out that cacheReadTokens / cacheWriteTokens are SUB-totals within inputTokens (not additional tokens on top). prevents future dashboards from double-counting by ~2x when summing "total tokens used". |
||
|
|
57bd10d6dd |
run-issues fixes: #5, #11, #12, #15, #16/#25, #20, #21, #22, #31 (#546)
* fix(#15): precompute diff anchors in checkout_pr TOC * test(#15): update TOC snapshot for precomputed diff anchors * chore(tests): skip codex-mini-latest models.dev check + refresh latest-by-provider snapshot * fix(#22): add commitCount and commitLog to checkout_pr return * fix(#21): include PR body in checkout_pr return * fix(#5): force-fetch PR refspec to overwrite stale local branch * fix(#31): rename git tool parameter from subcommand to command * fix(#11): soft-fail post-checkout hook, bump timeout to 10min * fix(#16): strengthen diff file usage guidance Agent was bypassing diffPath and running `git diff` instead. Tighten instructions in `checkout_pr` result and remove the mixed-signal "log, diff" listing in the global Git guidance. `git log` and `git diff --stat` remain allowed for commit-range overview. * fix(#20): drop invalid inline review comments instead of failing review Previously, a single inline comment anchored outside a diff hunk would 422 the entire review submission. Pre-validate comments against the PR file patches via listFiles, drop the invalid ones, and append a note to the review body listing what was skipped. Include the dropped list in the tool response so the agent can retry targeted fixes. * fix(#12): stop MCP server on inner activity kill + filter reconnect noise Inner-activity-kill zombies were burning multi-hour runner time because mcp-proxy's SSE reconnect and provider-error retry lines kept the outer activity timer alive long after the agent subprocess was killed. - Filter [mcp-proxy] / "provider error detected" chunks so they don't count as outer-timer activity. - Add onActivityTimeout callback to spawn + thread through agent runs. - main.ts wires that callback to stop the MCP HTTP server (so reconnects finally fail instead of looping) and arms a 5min safety-net timer that force-rejects the outer timer if the agent promise is still pending. * audit: harden #12 lifecycle + cover #20/#12 with unit tests Bugs found during Ralph audit of the prior run-issues fixes: - main.ts's 5min safety-net setTimeout was never cleared on the happy path; also activityTimeout.stop() didn't null the internal rejectFn, so a late forceReject from the safety-net could still reject a long-resolved promise. Timer now cleared in finally; stop() now disarms forceReject. - mcp server disposal was non-idempotent, so the inner-kill path ran server.stop() twice once the outer `await using` block exited. Made the returned disposer idempotent. Tests: - action/mcp/review.test.ts: 14 tests for commentableLinesForFile (multi-hunk, no-count hunks, no-newline marker, empty) and validateInlineComments (file not in diff, wrong side, out-of-range line and start_line, partitioning batches, default side). - action/utils/activity.test.ts: 6 tests for isActivityNoise covering mcp-proxy lines, provider-error lines, mixed chunks, Buffer input. * audit(#22): cap commitLog at 200 + scope git-diff restriction to PR review - cap git log --oneline at 200 entries so a PR with thousands of commits cannot blow up the MCP tool response; expose commitLogTruncated so callers can warn the agent when the log was clipped - tighten instruction wording so `git diff` / `git diff --cached` remain available for inspecting an agent's own uncommitted changes, while PR review content must still come from diffPath Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11,#22,#31): surface hook/commit warnings in instructions + polish git tool - append hookWarning + commitLogTruncated advisories to checkout_pr instructions so the agent actually sees the warning inline, not just as a field it may skip - fix stale 'subcommand' wording in git tool redirect for `pull` and in the `command` parameter description; the MCP parameter is named `command` now, and that's what the agent binds to Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#20): reassign params.comments even when all inline comments dropped if every inline comment fails pre-validation, the earlier guard skipped reassigning params.comments, so the submission still carried the bad comments and GitHub 422'd on the whole review. always reassign to validation.valid so the downstream 'nothing left to post' skip fires and an otherwise-empty review is no-oped cleanly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#22): degrade gracefully when base ref isn't resolvable checkout_pr used to assume \`origin/<base>\` is always reachable, but it isn't guaranteed after a shallow fetch that only pulled down the PR head. Failing the whole checkout over metadata we added for ergonomics would be a regression, so wrap the rev-list / log in a try/catch and return empty commit metadata instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): anchor noise patterns to line start to avoid false positives before this, a line like "agent said: [mcp-proxy] was there" or "context: provider error detected in log" in real agent output would have been treated as noise and failed to reset the outer activity timer. both patterns now anchor at the start of the (optionally debug-timestamped) line, matching only lines mcp-proxy or our own log.info actually emit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): export and unit-test formatDroppedCommentsNote covers single-line `path:N`, multi-line `path:start-end`, and startLine==line fallback so changes to the dropped-comments note format surface in test diffs instead of only in GitHub UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): cap dropped-comment note to stay under GitHub body limit a pathological run (agent emits hundreds of invalid inline comments on a huge PR and they all get dropped) would push the review body past GitHub's ~65KB limit and fail the whole submission with a body-too-long 422 — the exact all-or-nothing failure #20 was meant to prevent. cap the detail list at 50 entries with a "…and N more" line so the note stays bounded. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): distinguish binary/no-patch files in dropped-comment reason previously a comment on a binary file (or pure rename / mode-only change) was dropped with "line X is not inside a diff hunk", which misleads the agent into retrying with different line numbers. call out the no-textual-diff case explicitly so the agent knows to move that feedback to the review body instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11): replace lifecycle timeout string-match with typed sentinel spawn() now rejects with SpawnTimeoutError (code === SPAWN_TIMEOUT_CODE or SPAWN_ACTIVITY_TIMEOUT_CODE) instead of a plain Error. executeLifecycleHook now branches on that code so rewording the error message in subprocess.ts can no longer silently misroute timeouts into the "transient — retry" warning. * audit(#12): route agent hung-vs-failed via typed SpawnTimeoutError claude.ts and opentoad.ts decide between "hung" and "failed" log wording based on the subprocess error. move them off the literal "activity timeout" substring match onto the same SPAWN_ACTIVITY_TIMEOUT_CODE sentinel used by lifecycle.ts so all three call sites agree on the source of truth. * audit(#20): delete leftover pending review when submit fails Why: `createAndSubmitWithFooter` creates a PENDING review first so we can mint Fix-links with the review ID, then submits. If submitReview fails (e.g. 422 from a race where the diff moved between pre-validation and submission), the draft was left on the PR. GitHub only allows one pending review per user, so the agent's retry would then fail with "already has a pending review" — an error the agent has no tools to clean up from. Best-effort cleanup: delete the pending draft on submit failure before re-throwing the original error, so retries start from a clean slate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#31): point agent to concrete alternative when rebase/bisect blocked Why: in disabled-shell mode, `git rebase` and `git bisect` are blocked as arbitrary-code-execution escape hatches. Previous error messages explained *why* but left the agent without a next step — especially painful right after the `pull` redirect, which suggested "merge or rebase locally." The agent would follow that advice, hit the rebase block, and loop without knowing what to try next. Now: rebase block explicitly says "use 'merge' instead"; bisect block notes that manual bisect is also unavailable through this tool; pull redirect no longer recommends rebase in shell-disabled contexts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: import security tables into security.test to prevent drift Why: the security tests re-declared AUTH_REQUIRED_REDIRECT, NOSHELL_BLOCKED_SUBCOMMANDS, and NOSHELL_BLOCKED_ARGS inline with hand-copied message strings. When the runtime messages in git.ts were tightened (recent rebase/bisect guidance updates), the test copies drifted and tests validated a stale version of the logic while passing clean. A missing or mistyped entry in git.ts could therefore slip through. Now: export the tables from git.ts and import them into the test file. If a runtime message changes, the tests exercise the new string automatically; if an entry is added or removed, tests covering that command see the change without manual sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: widen pending-review cleanup to cover pre-submit throws getApiUrl() (invoked in footer build) can throw if API_URL is misconfigured, which would leak a pending draft between createReview and the previous submitReview try/catch. Move the try/catch to wrap the entire post-create body so any throw routes through deletePendingReview cleanup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: reject leading-dash refs/branch names to block flag injection git's parseopt accepts options intermixed with positional args, so a ref like "--upload-pack=evil" passed to git_fetch could be parsed as a flag rather than a refspec. Add a narrow rejectIfLeadingDash helper to git_fetch (ref), delete_branch (branchName), and push_branch (branchName). HTTPS remotes ignore --upload-pack server-side, but the hygiene matters for defense in depth (ssh remotes, future code paths). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: validate the resolved branch in push_branch too When branchName is omitted, rev-parse surfaces the current branch name, which could start with '-' if git state was tampered with. Move the leading-dash check to after the branch is resolved so both the explicit and derived paths go through validation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: cache commentable-lines snapshot at checkout to match review anchor Review comments are anchored to checkoutSha (commit_id), but validation was hitting pulls.listFiles at review time — latest HEAD, not the SHA the agent actually reviewed. If the PR was updated mid-run, valid comments could be silently dropped (or invalid ones admitted). Snapshot the commentable lines during checkout_pr so review-time validation matches the anchor exactly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): route activity monitor's own debug output around the write wrap startProcessOutputMonitor monkey-patches process.stdout.write to mark activity, then called log.debug(...) every 5s to report idle time — which landed right back in its own wrapper, failed isActivityNoise, and called markActivity. with ACTIONS_STEP_DEBUG=true (common on reruns) the idle counter reset every interval and the timeout could never fire, re-creating the #12 zombie-run bug for any debug-enabled run. Fix: capture the original stdout.write and use it directly for the monitor's own diagnostics so they bypass the feedback loop. Added a tight-timeout regression test that asserts the timeout still rejects in debug mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): noise-filter subprocess.ts monitor logs so outer timer survives debug activity.ts's own monitor output already bypasses the wrap (c35cd3fb), but subprocess.ts's spawn activity timer uses log.debug — which goes straight through process.stdout.write and would still mark activity on every interval when debug logging is enabled. Pattern-filter those '(spawn|process) activity (check|timer|monitor)' lines in both local ([DEBUG] ...) and GH-runner (::debug::...) formats so they don't reset the outer agent-hang timer. Kept scoped to those specific monitor messages — a blanket [DEBUG] filter would silently classify any coincidentally-debug-prefixed agent output as idle, which is a worse failure mode than the one we're fixing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11): surface spawn ENOENT-style errors in stderr buffer spawn() resolved with exitCode=1 and an empty stderr when the command itself couldn't start (missing binary, bad permissions). lifecycle.ts then reported 'output: (empty)' to the user, who was explicitly told 'retry if the failure looks flaky' — so every run hit the same wall with no diagnostic trail. Append the '[spawn] <cmd>: <node error>' line to stderrBuffer before resolving so the real cause (ENOENT, EACCES, …) flows through to the hook-warning message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11,#12): cover executeLifecycleHook typed-timeout routing the typed SpawnTimeoutError + sentinel-code branching introduced in d7ee7fd2 / ea8dd2c4 classifies hung vs failed lifecycle hooks — critical for whether agents retry — but had no unit coverage. add tests for all four branches (no script, exit 0, non-zero exit with retry-if-flaky guidance, timeout with do-NOT-retry guidance, transient spawn failure). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: re-verify clean tree after prepush hook the pre-prepush check guarantees we enter the hook with a clean tree, but if the hook writes tracked files (formatter, type generator, build artifacts), the push still only sends the pre-hook commit — the hook's edits silently disappear from the upstream branch while the tool reports "successfully pushed". add a post-hook status check so the agent sees the dropped mutations and can commit or discard them before retrying. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: reject push_tags refspec injection via ':' in tag name without tag validation, a tag like "foo:refs/heads/main" concatenated into "refs/tags/${tag}" becomes a valid <src>:<dst> refspec — git pushes the local refs/tags/foo's commit to remote main, bypassing push_branch's default-branch guard. same shape blocks leading '-' (flag injection) and other refspec metacharacters (~ ^ ? * [ \) via an allow-list regex. only reachable in push=enabled today, so this is defense-in-depth, but hardens the tool in case push_tags is ever exposed in restricted mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: stop pointing agents at an internal constant they can't change the lifecycle-hook timeout warning told agents to "bump LIFECYCLE_HOOK_TIMEOUT_MS" — but that's a hard-coded constant in the action, not something the agent or repo owner can tune. the agent would plausibly loop hunting for where to change it. redirect to the actual lever they control: ask the repo owner to simplify the hook. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: drop inverted inline-comment ranges locally with precise reason validateInlineComments only checked that both line and start_line anchor inside a hunk, not that start_line <= line. an inverted range (e.g. start=44, line=42) would pass local validation and GitHub would 422 with "invalid line numbers" — opaque to the agent and unfixable without reading docs. reject locally with a reason that names the constraint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: don't let usage-summary write error mask main's outcome writeGitHubUsageSummaryToFile is called in main's finally block. it can throw on ENOSPC / EACCES / missing parent dir. a throw here propagates past the try's successful return or the catch's error return, hiding the actual run outcome behind an I/O failure on a purely informational file. swallow the write error (debug-logged) — the summary is nice-to-have, not load-bearing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: don't mislabel agent handler errors as JSON parse failures the onStdout event loop wrapped both JSON.parse and the handler call in one try/catch that logged every caught error as 'non-JSON stdout line'. if a handler threw (e.g. todowrite state shape drift), the error was silently classified as a parse error, making diagnosis impossible. split the try blocks so JSON errors and handler errors get distinct, identifying log lines. * audit: reject leading-dash PR refs before they reach git commands PR head/base refs come from GitHub and are attacker-controlled on fork PRs (the PR author picks headRef freely). they flow straight into `git fetch origin <ref>`, `git checkout -B <ref>`, and config writes. without a leading-dash check, a ref named like '-upload-pack=evil' could be parsed as a flag instead of a refspec. validate both refs at the top of checkoutPrBranch (before any async work) and cover the two attack shapes with unit tests. * audit: cover ActivityTimeout.stop()'s forceReject disarming main.ts's safety-net-timer path depends on ActivityTimeout.stop() nulling out rejectFn so a late safety-net fire after a successful agent run is a no-op. that behavior had no direct coverage — removing the \`rejectFn = null\` in stop() would silently break the happy path (unhandled rejection / spurious failure) without failing any test. add three tests covering: forceReject rejects with the reason, stop() disarms forceReject, and forceReject after timer rejection is an idempotent no-op. * audit: stabilize activity-timeout idleSec against late stdout race * audit: reject 0ms timeout parses to avoid insta-fail from '0m' * audit: surface raw GitHub error on review 422 instead of assuming anchor cause * audit: key commentable-lines cache by PR number to prevent cross-PR drift * audit: enumerate concrete 422 causes and name checkout_pr in review error * audit: stop shipping ralph-loop runtime state in PR history .claude/ralph-loop.local.md and .claude/ralph-loop-prompt.md were accidentally staged in an earlier audit commit. the .local.md suffix is conventional for gitignored runtime state, and the prompt file is per-run harness config — neither should merge to main. ignore the pattern and untrack the existing entries (files remain on disk so the active loop keeps working). * audit: pin commentable-lines cache to checkoutSha, not just PR number a second checkout_pr(N) call advances toolState.checkoutSha at line 305 or 334, then runs fetchAndFormatPrDiff + cache population at line 549. any throw between those two points (rate limit, 5xx, network blip) left the old snapshot keyed to (pullNumber=N) while checkoutSha now points at a different sha. review_pr(N) would reuse the stale snapshot, silently validating comments against the wrong anchor — the original failure this cache was meant to prevent. track commentableLinesCheckoutSha alongside the pull number and require both to match before returning the cache. if either has moved, fall back to listFiles like any other miss. * audit: auto-clear leftover pending review from killed prior runs a workflow timeout or OOM between createReview PENDING and submitReview leaves GitHub holding a pending draft. the next run hits GitHub's one-pending-per-user-per-PR limit and 422s at pending-create, with no way to recover short of a human cleaning up manually. catch 422 at pending-create, list the PR's reviews (GitHub only exposes our own pending to us, so the filter is safe), delete the leftover, and retry once. 404/422 on the cleanup are treated as no-ops (race with another concurrent cleanup or the draft was submitted); any other cleanup error rethrows so the real cause reaches the caller. * audit: extract + unit-test stranded-pending-review cleanup the recovery branch inside createAndSubmitWithFooter had no direct test coverage. a regression in any of its guards (status check, message match, listReviews filter, 404/422 tolerance, non-retryable rethrow) would silently cause either destructive deletes of unrelated reviews or the old failure mode where a stranded pending draft blocks every retry. extract to clearStrandedPendingReview so the cases can be exercised with a mocked octokit, and add tests for each branch — including the load-bearing negative cases (non-422 passthrough, non-pending-review 422 passthrough, no-leftover-found passthrough, non-retryable cleanup error passthrough). no behavior change at the call site. * audit: document concurrent-run race in clearStrandedPendingReview two runs on the same PR using the same GitHub App installation token would both see each other's PENDING draft via listReviews (GitHub exposes PENDING only to the author, and both runs share authorship). the loser's recovery path would delete the winner's active draft, causing the winner's submitReview to 404. no reliable in-request signal distinguishes a genuinely-stranded prior-run draft from an active peer's draft — PENDING reviews have no created_at, and the user field is the same bot in both cases. the correct fix is workflow-level concurrency (a per-PR concurrency key), not a heuristic here. document the limitation so future readers don't try to bolt on a broken heuristic. * audit: report signal-killed subprocesses as failures, not exit code 0 node's close event delivers (code=null, signal=<name>) when a child is killed by signal (OOM killer, segfault, external SIGTERM). the close handler captured only exitCode and coerced null to 0 via `exitCode || 0`, so lifecycle hooks killed by signal were silently reported as successful — lifecycle.ts's `if (result.exitCode !== 0)` check skipped the warning and callers proceeded as if setup/post-checkout/prepush had completed. now capture signal, append "killed by signal <name>" to stderr, and resolve with exitCode=1 when code is null but signal is set. adds a regression test that spawns `kill -KILL \$\$` and asserts a non-zero exit plus the signal-kill marker in stderr. * audit: untrack RUN_ISSUES*.md ralph-loop working docs same pattern called out in 4f14dbf1: these files are per-run harness state and analysis scratch, not merge-to-main deliverables. the TODO literally opens with "Ralph loop instructions:", so it's unambiguously in the same category as .claude/ralph-loop-prompt.md was. files stay on disk so the active loop keeps working. * audit: block refs/... + symbolic-ref bypass of default-branch guard push_branch's restricted-mode guard compared the resolved remoteBranch against defaultBranch with exact-string equality. an agent passing branchName "refs/heads/main" flowed through: rejectIfLeadingDash passed, getPushDestination's fallback preserved the refs/heads/main string as remoteBranch, so "refs/heads/main" !== "main" and the block was skipped, yet git push happily resolved refs/heads/main to the local main commit and pushed to the remote main branch. symbolic refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) are the same class of bypass — they resolve to whatever commit they point at, unconstrained by the name-based guard. add rejectSpecialRef to enforce bare branch names at the tool entry, use it in push_branch and delete_branch. checkout_pr only ever assigns pr-<number> as the local branch, so nothing legitimate relied on the refs/... form here. * audit: keep original 422 visible when listReviews fails during pending-review cleanup if listReviews threw (e.g. transient 502, rate limit) during the stranded pending-review recovery path, the listing failure replaced the original 422 "pending review" error when it propagated up through the tool's outer catch. agents then saw a generic server error with no mention of the real blocker and stopped retrying the cleanup. now the listing failure is logged at debug but does not mask the original 422. the caller's retry re-attempts cleanup, which succeeds if the listing failure was transient. * audit: block default-branch deletion even under push: enabled delete_branch required push: enabled, but within that mode the agent could delete the default branch with no local guard. GitHub branch protection usually catches this at the remote, but not every repo has protection configured — and even when it does, relying on remote config for local safety is wrong. pushing to main is reversible (revert, force-push old HEAD); deleting main is not (reflog recovery only, 30-day window). block deletion of the resolved default_branch in DeleteBranchTool regardless of push permission. push: enabled authorizes pushes, not wholesale removal of the repository's primary branch. * audit: attach no-op catch to agentPromise so a late rejection can't crash cleanup agentPromise raced against activityTimeout.promise (and the --timeout timeoutPromise), both of which had .catch(() => {}) handlers. agentPromise did not. if a timeout won the race, agentPromise became stranded and its subsequent rejection was an unhandled rejection — under node 15+'s default unhandled-rejection policy that terminates the process, which would kill main() mid-cleanup and lose the error-reporting and usage-summary work queued in the catch/finally blocks. the race still sees the rejection (the original promise is shared); this catch only prevents node from treating a post-race rejection as unobserved. * audit: close push_branch refspec-injection via ':' / '+' in branchName rejectSpecialRef only forbade leading-dash, `refs/` prefix, and symbolic refs. git push accepts `[+]src[:dst]` refspec syntax, so an agent under push:restricted could smuggle a full refspec through branchName and bypass the downstream exact-string default-branch guard: "evil:refs/heads/main" → push local 'evil' to remote main ":refs/heads/main" → delete remote main ":other" → delete arbitrary branches (outside grant) "+main" → force-push refspec prefix reject ':', '+', '^', '~', '?', '*', '[', '\\', and whitespace — git's own check-ref-format forbids all of them in branch names, so the allow-list cannot false-positive against a legitimate branch. add regression tests. * audit: stop suggesting blocked 'rebase' in push_rejected advice under shell=disabled Why: when push fails with non-fast-forward, the advice told the agent to run 'git rebase origin/...'. In shell=disabled mode the git MCP tool blocks rebase (as an arbitrary-code-execution escape hatch), so the agent's only path forward was to hit the block, read the fallback message, and try merge — one wasted round trip. Now: under shell=disabled we directly suggest 'git merge origin/...', which always works. Under other modes the advice keeps the rebase/merge choice but leads with merge so the example is copy-pastable either way. * audit: harden includeIf cleanup against shell-injection via subsection names setupGit read `includeif.*` keys via `git config --get-regexp`, split on the first space, and fed the result into `execSync(\`git config --unset "${key}"\`)`. git config subsection values preserve arbitrary characters, so a crafted `[includeIf "gitdir:$(touch${IFS}/tmp/pwn)safe"]` entry round-trips through `--get-regexp` with its `$(...)` command substitution intact, survives the split-on-space filter (IFS-bypass leaves the payload space-free), and gets evaluated when interpolated into the shell command. Confirmed reachable as an RCE sink in local repro. Switch to `--get-regexp -z` (null-terminated, no ambiguity on whitespace) and call `$("git", ["config", "--unset-all", key])` which uses spawn-array and never hands the key to a shell. Extract the logic into `removeIncludeIfEntries` and add regression tests covering the injection payload, whitespace-in-subsection keys, benign entries, and the no-op case. * audit: clear SIGKILL escalator on clean SIGTERM exit the overall-timeout path scheduled a 5s SIGKILL follow-up without capturing the timer id. if the child cooperated with SIGTERM and `close` fired promptly, the escalator stayed pending in the event loop for up to 5s — delaying any subsequent clean shutdown (e.g. the main action exiting after an agent timeout) by that long. capture sigkillEscalatorId alongside timeoutId and clear it in both close and error handlers. regression test asserts the active-timer count does not grow past the pre-spawn baseline after a timed-out child exits on SIGTERM. * audit: correct rebase-availability hints to reflect shell=restricted the MCP git tool only blocks rebase when shell=disabled (NOSHELL_BLOCKED_SUBCOMMANDS check in GitTool). under shell=restricted, git({command: "rebase"}) works fine through the tool — NOSHELL_BLOCKED_SUBCOMMANDS doesn't apply. but two agent-facing messages implied rebase is only available with shell=enabled: - AUTH_REQUIRED_REDIRECT["pull"] said "rebase is only available when shell is enabled" - push-rejected integrateStep (non-disabled branch) said "(or 'rebase' if shell is enabled)" under shell=restricted, agents reading these would wrongly think they had to pick merge — pushing them toward merge commits when rebase would have been cleaner. the push-rejected branch is already ternary-gated on shell !== "disabled", so the qualifier there was just redundant noise. * audit: block difftool/mergetool under shell=disabled git difftool -x <cmd> is the short form of --extcmd. the args blocklist only matches --extcmd / --extcmd=*, so -x slipped through and let an agent run arbitrary commands even when shell=disabled. globally blocking -x would false-positive on git cherry-pick -x, which only appends metadata, so block difftool (and mergetool, same shape via mergetool.<name>.cmd) at the subcommand level instead. agents have no legitimate need for either — diffs go through diff/show and merges are resolved by file edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: recover stranded PENDING drafts on no-body createReview too The body path already clears a stranded PENDING draft from a prior crashed run via createAndSubmitWithFooter's own try/catch. The no-body path (approve-with-no-feedback or comments-only) called createReview directly — so a PR whose previous body-path run crashed between createReview(PENDING) and submitReview would permanently 422 any subsequent no-body review with "already has a pending review" until a body-path run happened to clear it. Factored out createReviewWithStrandedRecovery so both paths get the same recovery treatment, and added regression tests covering the no-stranded / stranded-and-retry / non-stranded-422-no-retry cases. * audit: reject timeouts past node's setTimeout ceiling a user-supplied timeout like "999h" parses fine (parseTimeString has no upper cap) but falls off the 2^31-1 ms limit setTimeout clamps to 1ms. the agent run would reject with "timed out after 999h" in a single tick. extract a resolveTimeoutMs helper that centralizes the zero/overflow/ unparseable checks (previously scattered behind inline boolean logic in main.ts) and cover the behavior with unit tests including the boundary value. * fix(#22): replace parameter property in SpawnTimeoutError node --experimental-strip-types rejects readonly/public/private param properties in constructors. tests run via node directly (no tsc), so CI was hitting ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX on every action-agents / action-agnostic job before any test code ran. declare the field and assign in the body instead. * audit: tighten git tool description and delete_branch refspec - `git` tool description previously implied `pull` had a dedicated MCP tool alongside `push_branch`/`git_fetch`. it doesn't — the redirect sends the agent back to the same git tool with `command: "merge"` (or `rebase`). update the description to teach this directly instead of letting agents discover it through the redirect error. - `delete_branch` now passes `refs/heads/${branchName}` to `git push --delete` so a same-named tag can't be silently deleted when both exist on the remote. `rejectSpecialRef` already guarantees the bare-name invariant, so the template construction stays injection-safe. Made-with: Cursor * audit: polish review.ts per anneal findings - drop `as "LEFT" | "RIGHT"` cast in `validateInlineComments` — octokit types `side?: string` at the createReview endpoint, so narrow via `c.side === "LEFT" ? "LEFT" : "RIGHT"`. no cast, no redundant annotation — TS infers the literal union from the ternary. - consolidate `clearStrandedPendingReview` from 3 params to 2 by folding `originalErr` into `params`, per AGENTS.md "max 2 parameters" rule. updates both call sites (`createReviewWithStrandedRecovery`, `createAndSubmitWithFooter`) and all 7 test paths. - upgrade `listReviews`-during-cleanup failure log from `log.debug` to `log.info` so operators not running at debug still see that recovery was attempted before the original 422 bubbles up. message now reads "surfacing original 422" to make the intent unambiguous. Made-with: Cursor * audit: signal partial commit metadata in checkout_pr previously a rev-list/log failure (e.g. shallow fetch where `origin/<base>` isn't reachable) silently returned `commitCount: 0, commitLog: ""` — indistinguishable from "this PR has no commits past base", which could mislead review reasoning about scope. add a `commitLogUnavailable: boolean` field to `CheckoutPrResult`, set when the rev-list/log calls throw. instructions footer now tells the agent to treat the values as "unknown" rather than "no commits" in that case. message phrased to cover the rare case where rev-list succeeds but git log throws (partial, not strictly zero) metadata. Made-with: Cursor * audit: fix parseDiffTocEntries to match production ' · diff-<sha>' TOC suffix the regex required $ right after the line range, but formatFilesWithLineNumbers in checkout.ts appends ` · diff-<sha256>` so agents have the GitHub "Files Changed" anchor precomputed. result: tocEntries was always empty on real PR reviews, breakdown.files was empty, and runDiffCoveragePreflight never fired its one-time "read the diff" nudge. add an optional suffix to the regex and a regression test that uses the exact production TOC shape. Made-with: Cursor * audit(#20): skip empty downgraded-APPROVE reviews before they 422 GitHub rejects `event: "COMMENT"` reviews with no body and no inline comments (HTTP 422 "Unprocessable Entity", verified empirically on repos/pullfrog/preview-546-run-issues-fixes/pulls/1). the runtime `prApproveEnabled` downgrade folds approved=true into event=COMMENT when the repo flag is off, so an agent asking to APPROVE a PR with no other feedback produces exactly that rejected shape — but the existing empty-review skip only fired for !approved cases, so the tool POSTed the doomed COMMENT, octokit returned what looked like a success-with- no-persisted-review shape, and agents reported a phantom reviewId that 404s on any subsequent GET. extract the skip decision into `reviewSkipDecision` and add a second branch for approved + !prApproveEnabled + empty. the function returns null when the review should be submitted, so a real bare APPROVE (approved + prApproveEnabled + empty) still goes through unchanged — GitHub accepts empty APPROVE reviews because the stamp itself is the content. surfaced in the PR #546 preview e2e run 24678139563 (reviewId 4141786854 reported by the agent but absent from every reviews listing). TC13 run 24680349445 re-ran the same scenario with prApproveEnabled=enabled and the review persisted correctly, isolating the cause to the downgrade + empty interaction. * audit(#31): drop misleading rebase mention from pull redirect AUTH_REQUIRED_REDIRECT["pull"] and the git tool's top-level description both said "use git_fetch then this tool with command 'merge' (or 'rebase' unless shell is disabled)". the "(or 'rebase' unless shell is disabled)" qualifier is active misinformation when the agent is already running under shell=disabled: rebase is blocked there by NOSHELL_BLOCKED_SUBCOMMANDS, so the suggestion sends the agent into a second block on the next tool call. 3b83ee97 already fixed this pattern for the push-rejected advice at line 248, but the pull redirect at line 280 and the tool description at line 351 were missed. the right copy isn't a conditional qualifier that agents have to parse against their own shell mode — it's just naming the one alternative that works everywhere (merge). agents under shell=restricted/enabled who want rebase can invoke it directly; the redirect doesn't need to advertise it. verified in preview e2e run 24679728733 (TC8 probe 6) where the agent correctly captured the verbatim redirect message under shell=disabled and explicitly flagged the "(or 'rebase' unless shell is disabled)" clause as confusing — the new test in security.test.ts asserts the message names merge and never rebase in every shell mode. * audit: drop vestigial entry/post references + add preview-546 settings util followup to d79860c6 "refactor: flatten action entrypoints" (Apr 10), which moved action.yml from built `entry`/`post` files to source `entry.ts`/`post.ts` but left three stale references lying around: - .gitignore: `action/run/entry` / `action/dispatch/entry` paths no longer exist anywhere in the build. - .github/workflows/pull-from-action.yml: agent instruction told the upstream sync agent to "Ignore `entry` files (they are built artifacts and .gitignored in this repo)". there are no built entry artifacts anymore — entry.ts is source. - .cursor/settings.json: search.exclude pattern "**/entry" excluded the old built files that no longer exist. none of these were load-bearing on their own, but the same drift had already broken preview e2e end-to-end: the pullfrog/template workflow's three-file copy step (cp .../entry, cp .../post) silently failed with cp: no such file on every preview PR since Apr 10. that template fix went to pullfrog/template@7ec7c8d and the preview-546 mirror at @17ab585, which is what unblocked this PR's full e2e validation. also adds scripts/preview-546-settings.ts, the helper used during the e2e validation to show/set/reset DB-level repo settings on the Neon preview branch (push, shell, prApproveEnabled, hook scripts). scoped to this preview repo ID so it can't accidentally mutate prod. * audit(#11): scope removeIncludeIfEntries to repoDir under inherited GIT_* the function takes `repoDir` as the target, but plain execSync / $(...) inherit GIT_DIR, GIT_WORK_TREE, and GIT_INDEX_FILE from the parent process — and `git config --local` honors GIT_DIR over cwd. when this runs as a child of another git invocation (notably the pre-push hook, but also any future caller embedded inside a git subcommand), the cleanup silently targets the outer repo instead of repoDir. latent today because the real caller is ASKPASS setup, which runs before any git-subcommand ancestor exists, but the function's contract still promised the wrong thing — and the test suite hit exactly this bug when invoked through `git push`. - envScopedToRepo() strips GIT_* before both the get-regexp and unset calls, so cwd wins. - swap the $(...) shell helper for execFileSync on the unset call. $() would merge our scoped env with a "restricted" base that's tuned for hook execution (no tokens) — overkill here and it re-introduces the shell-vs-argv distinction this function was explicitly hardened against in a9aa3b2b. execFileSync with argv is the right tool for a call where the key can contain arbitrary characters. - setup.test.ts also strips GIT_* in its own execSync harness so the suite passes identically under `pnpm vitest run`, `pnpm -r test`, and `git push`'s pre-push hook. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
c608051b79 |
sanitize mcp schemas for Gemini; fix gpt-codex-mini alias; add matrix filter
Gemini's generateContent API rejects arktype's `{anyOf:[{enum:[...]}]}` string-enum
encoding, `$schema` metadata, and `anyOf` with sibling fields. Port the old
sanitizer back as an isolated module (action/mcp/geminiSanitizer.ts) and gate it
on `isGeminiRouted(ctx)` so non-gemini routes see the original schema. Wires
`resolvedModel` onto ToolContext so the sanitizer can see the upstream specifier.
Also bumps `openai/gpt-codex-mini` alias from the deprecated `codex-mini-latest`
to `gpt-5.1-codex-mini`, matching the openrouter resolve.
Adds a `filter` workflow_dispatch input + MATRIX_FILTER env that restricts the
models-live matrix to aliases matching a substring, so we can iterate on a
single provider (e.g. `filter=gemini`) without paying to run every model.
|
||
|
|
56a5d29598 |
add diff coverage preflight for PR review submissions (#544)
* add one-time diff coverage preflight for PR reviews track diff read coverage from agent tool-use events and run a one-time pre-flight before review submission, with explicit coverage skip reasons for low-value files like lockfiles. Made-with: Cursor * add manual dispatch fallback for preview deploy workflow allow preview repo and preview sync jobs to be run via workflow_dispatch with explicit PR number and branch inputs, so preview provisioning can be retriggered when pull_request events fail to fire. Made-with: Cursor * fix manual preview dispatch PR input wiring use normalized PR number and branch env values for comment creation and script env wiring so workflow_dispatch preview runs can create and update PR-specific preview resources. Made-with: Cursor * remove obsolete snapshots invalidated by checkout instructions change * fix diff coverage read offset handling and add local sanity-check guidance normalize read offset semantics for diff coverage tracking, reuse shared range counting in review preflight, add focused diff coverage unit tests, and document the local play.ts testing workflow in AGENTS.md. Made-with: Cursor * add regenerated mcp test snapshots capture snapshot files generated by the review comment and checkout formatting tests during pre-push validation so the branch remains clean and reproducible. Made-with: Cursor * add diff coverage preflight instrumentation logs log diff coverage initialization in checkout_pr and emit preflight state/breakdown diagnostics in create_pull_request_review to debug missing coverage enforcement in preview e2e runs. Made-with: Cursor * add env override to force local cli execution in action runtime support explicit local-cli execution via PULLFROG_FORCE_LOCAL_CLI so preview workflows can run branch action code instead of the npm fallback package during e2e debugging. Made-with: Cursor * add preview e2e debugging learnings for action runtime validation capture the preview execution-path gotchas and one-time preflight verification pattern in AGENTS.md so future investigations validate the real runtime and avoid npm fallback confusion. Made-with: Cursor * reduce diff coverage log noise while preserving failure visibility downgrade verbose diff coverage lifecycle diagnostics to debug, keep a concise info-level pre-flight failure signal, and document preview runtime debugging learnings in AGENTS.md. Made-with: Cursor * WIP * tune sync.md: ff override + softer overlap verification Made-with: Cursor * chore: bump models snapshot for claude-opus-4-7 Made-with: Cursor * rip out coverage_skips waiver from diff coverage pre-flight Made-with: Cursor --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
74b313e612 |
bump claude-opus alias to 4-7
anthropic shipped claude-opus-4-7 today; opencode also republished it. point the "claude-opus" alias at the new version for both providers so existing users get the upgrade automatically. openrouter hasn't published 4.7 yet, so leave openRouterResolve at 4.6 as the BYOR fallback. also clarify the latest-model snapshot comment: new model drops usually just mean bumping the `resolve` on an existing alias, not adding a new one. Made-with: Cursor |
||
|
|
2d1f1d33db |
replace suffix-based env filtering with default-deny allowlist (#543)
* replace suffix-based env filtering with default-deny allowlist filterEnv() now only passes known-safe GitHub Actions runner/system/toolchain vars plus user-configured allowlist entries to shell subprocesses. GITHUB_TOKEN and GH_TOKEN are always blocked, even from the user allowlist. adds envAllowlist field to repo settings with dashboard textarea UI (visible only when shell isolation is enabled) and wires it through run-context API to the action runtime. Made-with: Cursor * address review: blocked-name warning, JAVA_HOME prefix, stale waitlist copy - setEnvAllowlist now strips BLOCKED_ENV_NAMES from user input and returns them so main.ts can log a warning - move JAVA_HOME to exact names, use JAVA_HOME_ as prefix for clarity - update stale suffix-based description in waitlist email script Made-with: Cursor * fix wiki/security.md snippet: JAVA_HOME -> JAVA_HOME_ to match code Made-with: Cursor * UI polish: field-sizing-content on all textareas, rename env allowlist label - add field-sizing-content to all settings textareas so they auto-expand to fit content (AgentSettings, ModesSettings, WorkflowsSettings, FlagsSettings) - rename "Environment variable passthrough" to "Environment allowlist" with clearer popover copy - drop "e.g." prefix from env allowlist placeholder - update docs/security.mdx and wiki/security.md references to match Made-with: Cursor * tweak env allowlist popover wording Made-with: Cursor * document default allowed variables in security docs with link from popover Made-with: Cursor * Update action/utils/secrets.ts Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
a120160f42 |
clean up startup run configuration logs
remove duplicate model and agent log emitters, then print model, agent, push, shell, and timeout on separate startup lines so run settings stay concise and easy to scan. Made-with: Cursor |
||
|
|
4b3c5ca905 |
rename agent key to opencode and add skill invocation coverage (#541)
* rename agent key to opencode and add skill invocation coverage. add skill-invoke tests for claude and opencode with local play-based validation signals, update CI matrices, and include the current tracked refactors in this branch for review. Made-with: Cursor * exclude agent-specific skill-invoke tests from wrong agent in CI matrix * address review follow-up and preserve workflow run UI tweak. switch changed-agents ci coverage to exercise the opentoad implementation path while keeping the opencode expectation, and include the local workflow run client interaction updates requested on this branch. Made-with: Cursor * remove opentoad agent filename from runtime. rename the opencode harness implementation file from opentoad.ts to opencode.ts and update ci coverage input accordingly so action code no longer carries the old filename. Made-with: Cursor * ensure security prompt bypass is set on every test fixture. this keeps adversarial and permissions harnesses from being blocked by the default prompt-injection refusal path during CI security tests. Made-with: Cursor --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
2799cce4bf |
homepage redesign + docs cleanup + agent prompting (#540)
* homepage redesign + docs cleanup + agent prompting improvements - rewrote hero section: new tagline, responsive font sizing with clamp(), extracted shared constants for copy management - added feature screenshots (shell isolation, github permissions, mcp tools, agent browser) and ensured consistent image sizing - reworked feature section mobile layout: caption-style descriptions, bigger h3s, image padding - made CTA buttons visible on all breakpoints (stacked on mobile, row on md+) - reorganized docs/tools.mdx into single table with category dividers, simplified tool descriptions - added markdown image syntax instruction to agent system prompt - fixed InfoPopover overflow on small screens - misc: InlineCode proportional sizing, OnboardingCard updates, shell/security doc improvements Made-with: Cursor * update pnpm-lock.yaml for agent-browser 0.25.4 Made-with: Cursor |
||
|
|
b748355cbe |
homepage copy refresh + fix skills CLI installation (#539)
* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage
Made-with: Cursor
* fix webhook race conditions: separate runId assignment from data updates
the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.
also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.
Made-with: Cursor
* homepage copy refresh + fix skills CLI installation
- update hero to "Agent x GitHub" with new subtagline
- rewrite intro paragraphs: workflow, harness capabilities, billing
- add feature sections: bash isolation, headless browser, MCP tools
- update FAQ answers, footer attribution, free-for-oss copy
- update APP_DESCRIPTION for SEO
- fix skills install: use npx from tmpdir instead of local binary
(the bundled action has no node_modules; running npx from tmpdir
avoids project .npmrc with pnpm settings breaking binary resolution)
- instruct agents to use markdown image syntax in upload_file tool
- start dependency installation eagerly from main.ts
- include event title in task instructions
Made-with: Cursor
|
||
|
|
426ef8c0d8 |
review: append todo list to review body, always delete progress comment
- in Review mode, stop the todo tracker and append the completed task list as a collapsible section to the review body before submitting - always delete the progress comment after a review is submitted, regardless of whether the agent called report_progress Made-with: Cursor |
||
|
|
b9b6503315 |
reorder prompt sections: task-first with dynamic TOC (#513)
* reorder prompt sections: task-first with dynamic TOC put the actual task at the top of the prompt for primacy, add a dynamic table of contents, and push system/runtime metadata to the end. new section order: TOC → YOUR TASK → PROCEDURE → EVENT CONTEXT → SYSTEM → LEARNINGS → RUNTIME Made-with: Cursor * enforce clean working tree: continue session if agent leaves uncommitted changes after each agent run, check `git status --porcelain`. if dirty, resume the same session with instructions to commit on a new branch, push, and open a PR. retries up to 3 times before giving up. - claude code: capture session_id from result event, use --resume <id> - opencode: use --continue to resume the last session - remove --no-session-persistence from claude (needed for --resume) - update Task mode to clarify branch/push/PR is the default finalize step Made-with: Cursor * log full prompt in collapsible group for debugging Made-with: Cursor * fix: format tool refs in buildCommitPrompt via formatMcpToolRef * enforce clean git status: general instructions, stop hook, and Task mode Made-with: Cursor * fix: rename stale titleBody references after body leak fix Made-with: Cursor --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
d525fc21be |
show fallback indicator in model dropdown, move agent logs to main, bump to 0.0.191
Made-with: Cursor |
||
|
|
b1f9878877 |
extract resolveModel() to run before agent selection
model resolution was duplicated inside each agent (opentoad, claude) and PULLFROG_MODEL override was not considered when choosing the agent. now resolveModel() runs first in main.ts, its result feeds into resolveAgent() for agent selection, and the resolved model is passed to the agent via ctx.resolvedModel. agents only handle their own fallback (opentoad: auto-select via opencode models, claude: strip provider prefix). also removes the hardcoded anthropic/claude-sonnet test runner default since ANTHROPIC_API_KEY is no longer in CI. Made-with: Cursor |
||
|
|
ba1966f17c |
feat: encrypted account-level secrets (#501)
* feat: add encrypted account-level secrets with UI for adding API keys Adds AccountSecret model with AES-256-GCM encryption, API routes for CRUD, "Add secret" button in model costs section, and injects decrypted secrets into action env (YAML secrets take precedence). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: repo secrets, sidebar icons, lazy learnings history - add repo-level secrets with inheritance from org secrets - add icons to console sidebar sections - fix learnings history modal: lazy fetch with hover prefetch, strip content from list response, load content per-expansion Made-with: Cursor * add input validation bounds for secrets and fix client-side name filter Made-with: Cursor * refactor: migrate all client-side data fetching to TanStack Query Replace manual useState/useEffect/fetch patterns and the custom usePolling hook with useQuery, useInfiniteQuery, and useMutation across the entire frontend for consistent caching, background refetching, and reactive invalidation. - ActiveWorkflowRunsSection: useQuery + refetchInterval - WorkflowRunHistory: useInfiniteQuery + polling query - LearningsSection: useQuery per revision (lazy) - FlagsSettings: self-contained useQuery + useMutation - SecretsCard: useMutation for delete - AddWorkflowButton, VerifyWorkflowButton: useMutation - EmailSignupForm, email-waitlist: useMutation - providers.tsx: enable refetchOnWindowFocus - Delete usePolling.ts (no remaining consumers) Made-with: Cursor * address PR review: squash migrations, rename accountSecrets → dbSecrets Squash the two separate secrets migrations into a single migration. Rename the wire format field from accountSecrets to dbSecrets since it now carries merged account + repo secrets. Made-with: Cursor * fix: update proxyKeys.ts imports after cache.ts -> yes package migration Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
0055aef618 |
feat: add Claude Code agent for Anthropic model users (#502)
* feat: add Claude Code agent for Anthropic model users Re-adds Claude Code support (removed in #478) so users with Anthropic API keys or Claude Code OAuth tokens can use their Claude subscriptions directly. When an Anthropic model is selected and Claude Code credentials are available, the system auto-selects the Claude agent instead of OpenCode. The harness mirrors opentoad's security model: native Bash blocked via --disallowedTools, MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON streaming, provider error detection, cache/cost tracking, browser skill, and todo progress tracking. Key changes: - action/agents/claude.ts: full Claude Code harness - action/utils/agent.ts: auto-select Claude for anthropic/* models - action/utils/providerErrors.ts: extracted shared provider error detection - action/utils/skills.ts: extracted shared skill installation (agent-aware) - action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars - action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist - CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused toolId variable, fix apiKeys test env cleanup The apiKeys test cleanup stripped *_API_KEY vars but missed CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip provider prefix from PULLFROG_MODEL in Claude agent the env override path was returning the raw value (e.g. "anthropic/claude-sonnet-4-5") without stripping the provider prefix, causing the Claude CLI to receive an invalid model ID. Made-with: Cursor * fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows remove unused cliPath from Claude agent RunParams, and pass CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates so users with Claude Pro/Team subscriptions can use their membership. Made-with: Cursor * fix: block Bash subagent in Claude Code disallowedTools Made-with: Cursor * chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free) Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
a7b8dcbced |
Rework incremental diffing (#499)
* Improve our deepening logic * Use consistent SHA for PR-related operations in CheckoutPrTool * compute `deepenDepth` at more appropriate time * fix stale comment * add comments for `alreadyOnBranch` * ensure before sha is available * small cleanup * computeIncrementalDiff * move the util * improve algorithm * improve algorithm further * get rid of temp result array * add comment * compute incremental diff and updte instructions * add comment * update stale comment * get rid of redundant rev-parse call * improve comment * strenghten the instructions * make diff paths unique |
||
|
|
cb8e33360c |
feat: add prepush lifecycle hook (#498)
* feat: add prepush lifecycle hook Add `prepushScript` configuration — an optional shell script that runs automatically before pushing code to the remote repository. Reuses the existing `executeLifecycleHook` infrastructure (bash execution, 2-min timeout, error propagation on non-zero exit). When unconfigured the hook is a no-op. Made-with: Cursor * fix: add prepushScript to run-context API response, fix UI separator Include prepushScript in the settings returned by the run-context endpoint so the hook actually fires in production. Also fix the separator pattern in AgentSettings to match the existing convention (spacer + hr + spacer instead of margin). Made-with: Cursor |
||
|
|
6b18b6730b |
live todo tracking, collapsible task list in final progress, hide set_output outside standalone (#492)
* fix false "without reporting progress" error + live todo tracking clean up orphaned progress comments when review is skipped or only set_output is used, preventing the false positive in handleAgentResult. parse todowrite events from OpenCode's NDJSON stream and render a live markdown checklist in the PR progress comment (2s debounce). agent's explicit report_progress always takes priority. Made-with: Cursor * fix contradictory review/progress prompting align Review and IncrementalReview mode prompts with their guidance — mode prompts said "always submit" while guidance said "skip if clean." remove the empty-approval submission that was silently dropped by the tool. make progress comment lifecycle explicit: created on first call, updated in place, removed after review submission. Made-with: Cursor * centralize todo tracking into shared TodoTracker module extract inline todo tracking logic (~95 lines) from opentoad.ts into action/utils/todoTracking.ts. the tracker is created once in main.ts and passed to agents via AgentRunContext.todoTracker, making it agent-agnostic and reusable for future agent implementations. Made-with: Cursor * fix todoTracker optional type to match file convention add | undefined to todoTracker in AgentRunContext, matching every other optional property in the same interface. Made-with: Cursor * instruct agents to always maintain a task list for live progress system prompt now tells agents to create an internal task list at the start of every run. the tracker renders it to the progress comment automatically. report_progress is reserved for final results only — no more intermediate "Checking..." messages that cancel the tracker and leave stale text on the comment. Made-with: Cursor * require report_progress summary at end of every run agents must always call report_progress with a final summary — the completed task list should never be the end state of the progress comment. updated all review mode prompts to call report_progress after submitting (or not submitting) a review. Made-with: Cursor * keep progress comment after review with final summary stop deleting the progress comment after review submission — the agent now always calls report_progress with a summary at the end, and that summary should persist as a record of what was done. Made-with: Cursor * harden stranded progress comment cleanup - main.ts: detect when tracker was last writer (agent never called report_progress) and delete the stranded checklist instead of leaving it as the final comment state - postCleanup.ts: expand stuck-comment detection to also catch stranded todo checklists (regex match for checklist patterns) when the process is killed before normal cleanup runs - modes.ts + selectMode.ts: add report_progress step to Summarize and SummaryUpdate modes (only modes that were missing it) Made-with: Cursor * fix stale comments, typo, and build mode redundancy - comment.ts: update deleteProgressComment docstring and inline comment to reflect current usage (stranded-comment cleanup, not post-review) - modes.ts: merge duplicate report_progress steps (8 + 10) into single step 9, fix "optimizatfixons" typo - wiki/post-cleanup.md: document checklist detection regex Made-with: Cursor * collapsible completed todos in final progress, hide set_output outside standalone mode - add renderCollapsible() to TodoTracker, append completed task list as <details> section when agent calls report_progress - cancel tracker after agent's final report_progress so it doesn't overwrite with raw checklist - conditionally register SetOutputTool only in standalone mode or when output_schema is provided - remove unconditional set_output instruction from orchestrator task section - update Summarize/SummaryUpdate mode guidance to not reference set_output Made-with: Cursor * show completion count in collapsible task list summary Made-with: Cursor * only count completed (not cancelled) in collapsible task list summary Made-with: Cursor * reinforce concise summary prompting across system prompt, modes, and tool description Made-with: Cursor * address review feedback: wasUpdated bypass, tracker false-positive, race condition - remove wasUpdated=true from cleanup paths so handleAgentResult correctly detects genuinely silent runs - add hadProgressComment to ToolState as immutable snapshot for the safety check - use todoTracker.hasPublished instead of enabled for stranded-comment cleanup - serialize onUpdate calls via inflightPromise chain with post-cancel guard - add settled() to wait for in-flight updates before writing final summary Made-with: Cursor * address round-2 review: hasPublished after success, finalSummaryWritten flag - set hasPublished only after onUpdate resolves (not before) so failed writes are not counted as published - add finalSummaryWritten flag to ToolState, set after successful non-plan reportProgress; decouple cleanup detection from todoTracker.enabled so it survives API failures where cancel() ran but the write didn't succeed Made-with: Cursor |
||
|
|
64f2238316 |
Repo Intelligence: agent-managed per-repo learnings with revision history (#487)
* add repo learnings feature with edit history introduces a new "Learnings" section in the repo console where agents can persist operational knowledge (setup steps, test commands, conventions) at the end of runs via an MCP tool. users can also edit learnings manually. - add `learnings` field to Repo model and `LearningsRevision` audit table - add `update_learnings` MCP tool for agents to persist repo knowledge - integrate learnings into prompt assembly as REPO LEARNINGS section - add learnings step to mode guidance (Build, AddressReviews, Plan, Fix, Task) - add PATCH /api/repo/[owner]/[repo]/learnings endpoint (JWT auth) - add GET /api/repo/[owner]/[repo]/learnings/history endpoint (Clerk auth) - add LearningsSection component with textarea, save-on-blur, and history modal - record revision history with actor tracking (agent vs user) and pruning (50 max) - gate UI behind owner === "pullfrog" for internal dogfooding Made-with: Cursor * fix prisma enum import path for LearningsActor Made-with: Cursor * simplify learnings schema: remove LearningsActor enum, store model name directly the actor/actorName split was unnecessary — learnings are only written by agents so the revision table just needs a model column. removes all user editing concepts from schema, API, and frontend. Made-with: Cursor * fix migration: add separate migration instead of rewriting existing one restores original learnings_revisions migration and adds a new migration that drops actor/actorName columns, backfills model from actorName, and drops the LearningsActor enum. Made-with: Cursor * polish learnings feature: rename to Repo Intelligence, fix atomicity, fix review skip - rename user-facing "learnings" to "Repo Intelligence" (UI, prompt section, wiki, sidebar) - simplify description to "Automatically discovered by the agent across runs." - wrap repo.update + revision create in $transaction for atomicity - refactor recordLearningsRevision to pruneLearningsRevisions (prune-only) - fix empty review skip: don't block APPROVE reviews with no body - fix broken docs anchor: #free-options → #free-models - update agent guidance to require flat bullet list format with pruning - add accessibility: aria-expanded, sr-only loading, output element - add chevron rotation, stale data clear on modal close, max-h scroll - trim + length-limit model field, remove type cast, restore pre-existing comment - update wiki prompt examples with actual bullet-formatted content - update model test snapshot Made-with: Cursor * fix stale free model name in docs, rename utility file to match export - docs/keys.mdx: MiMo V2 Flash → MiMo V2 Pro (matches model code change) - rename recordLearningsRevision.ts → pruneLearningsRevisions.ts Made-with: Cursor * Add skill, .neon * polish learnings UI and remove verbose log - learnings code block: read-only appearance with muted text, copy button, rounded corners - history modal: full-width rows with cursor-pointer, chevron moved to right, no preview text - drop noisy update_learnings log line Made-with: Cursor * inject learningsStep into all modes, drop seed script, soften revision styling Made-with: Cursor * Drop seed --------- Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
e6d34ee01b |
add OpenRouter proxy for managed model routing and OSS program (#488)
* add OpenRouter proxy for managed model routing and OSS program proxy layer that mints ephemeral OpenRouter keys for users without BYOK API keys. two paths: pro plan users get their selected model proxied via OpenRouter; OSS program repos (hard-coded allowlist in config.ts) get free Claude Opus. BYOK env vars (PULLFROG_MODEL/OPENCODE_MODEL) always take precedence. frontend: OSS repos see a static "Opus (Free)" badge with the model dropdown disabled and no API key requirement. all models now carry openRouterResolve metadata for proxy target resolution. Made-with: Cursor * implement OSS program: proxy infrastructure for free model credits server-side OSS allowlist determines eligible repos. action mints ephemeral OpenRouter keys via OIDC-authenticated /api/proxy-token endpoint (idempotent on runId, $10 per-key safety limit). keys are disabled on workflow completion when no running refs remain. HWM-based usage sync tracks cumulative spend per account. schema: ProxyKey model, Account.usageUsd/activeKeyId, WorkflowRun.proxyKeyId action: OIDC credential stashing, resolveProxyModel uses server oss flag frontend: isOss flows from server page to components (no client allowlist) Made-with: Cursor * address PR review: repo cross-check, key retirement lifecycle, dead code removal - proxy-token: verify runId belongs to OIDC-authenticated repo via repo relation - add retireKey() shared primitive: disable in OpenRouter first, then mark disabled in DB - rotateKey: retire old active key after swap to prevent orphans - webhook: replace inline cleanupProxyKey with retireKey calls - syncAccountUsage: skip disabled keys - remove vestigial AccountPlan/plan field from action types - add disabled field to ProxyKey schema + migration Made-with: Cursor * replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free Made-with: Cursor * fix migration ordering: rename disabled migration to sort after table creation Made-with: Cursor * squash proxy key migrations into single migration Made-with: Cursor * add preview repo to OSS allowlist for testing Made-with: Cursor * populate OSS allowlist from oss-program-invitees.json Made-with: Cursor * format oss-program-invitees.json Made-with: Cursor * add installed public repos to OSS allowlist split ossRepos into three provenance-tracked lists: - internalRepos (pullfrog, colinhacks, RobinTail) - installedPublicRepos (external public non-fork repos with active installs) - invitees (from oss-program-invitees.json) also adds scripts/list-oss-candidates.ts to regenerate the installed list Made-with: Cursor * fix: resolve tokens before clearing OIDC env vars resolveTokens → acquireNewToken → isOIDCAvailable() checks ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars. The new OIDC stashing code was deleting them in restricted shell mode before resolveTokens ran, causing it to fall through to the GitHub App path which requires GITHUB_APP_ID/GITHUB_PRIVATE_KEY. Made-with: Cursor * derive proxy-token auth from OIDC claims, add ensureWorkflowRun upsert - proxy-token no longer requires body.runId; uses claims.run_id + claims.repository - shared ensureWorkflowRun upsert called from both webhook and proxy-token - workflow_run_requested handler now eagerly creates WorkflowRun records - eliminates race condition between webhook and action proxy-token call Made-with: Cursor * hardcode PULLFROG_ACCOUNT_ID, document preview debugging lessons GitHub node IDs are constant — no reason for this to be an env var. Removes the trailing-newline bug that caused P2025 errors. Adds wiki docs on workflow testing, Vercel env gotchas, and Neon preview branch discovery. Made-with: Cursor * fix: parse OpenRouter create-key response correctly the API returns `key` at the top level, not inside `data` Made-with: Cursor * onboarding cards, unlock OSS model selection, simplify console - add OnboardingCard component with two states: workflow install and model+test (dispatches "Tell me a joke" for test run) - replace PromptBox overlay gates with dedicated onboarding cards; PromptBox is now just the form, always enabled - use hasWorkflowRuns DB check to decide onboarding vs promptbox - unlock ModelSelector for OSS repos (was locked to Opus badge); resolve proxyModel from repo's selected model alias in run-context - rename "API key" row to "Model costs" with pure client-side states: OSS covered, auto-resolve, free model, BYOK with env var names - add "(Recommended)" badge to model aliases with recommended: true - remove OSS_MODEL_DISPLAY_NAME constant and secrets-fetching logic Made-with: Cursor * update stale xai model snapshot Made-with: Cursor * rename Permissions to Security, hide git push toggle, add shell isolation toggle with disabled state for public repos, remove opentoad agent name from logs Made-with: Cursor * chevron hover states, sidebar hooks/security entries Made-with: Cursor * address PR review feedback: rename recommended→preferred, fix dispatch orphan, update wiki docs - rename `recommended` to `preferred` in model alias registry to distinguish from the UI "Recommended" badge (which is hardcoded for opus + codex only) - cancel precreated WorkflowRun when workflow lookup fails in dispatch-workflow - replace run_sql/vercel env pull in wiki docs with neonctl + Prisma pattern - extend scripts/neon-branch.ts to output DATABASE_URL via neonctl - add Recommended badge to GPT Codex alongside Claude Opus in ModelSelector Made-with: Cursor |
||
|
|
c6a3ee0e9a |
show model name in footer, drop pullfrog.com link (#484)
* show model name in footer, drop pullfrog.com link add model slug to buildPullfrogFooter so every Pullfrog comment displays the active model (e.g. "Using `Big Pickle` (free)" or "Using `Claude Opus`"). remove the pullfrog.com link from all footers. Made-with: Cursor * reject <br/> tags in comment bodies, add prompt guidance add runtime validation in addFooter that throws if <br/> is followed by a non-blank line (breaks GitHub heading rendering). the agent sees the error and retries with clean markdown. also update Summarize mode prompt to explicitly forbid <br/> tags. Made-with: Cursor * fix <br/> guidance: move to event instructions, clarify blank line rule the formatting rule belongs in DEFAULT_PR_SUMMARY_INSTRUCTIONS (event instructions), not the Summarize mode prompt. clarify that <br/> must always be followed by a blank line before headings. Made-with: Cursor * generalize block-level HTML rule in summary instructions add a prominent top-level rule about requiring blank lines between ALL block-level HTML elements and markdown syntax, not just <br/>. Made-with: Cursor * move model to toolState instead of threading through params model is set once at startup and read everywhere — it belongs on toolState, not threaded as a separate param through 8 call sites. postCleanup runs without toolState so it just omits the model label. Made-with: Cursor * update models.dev snapshot (openai latest changed) Made-with: Cursor * add comment to models snapshot test explaining its purpose Made-with: Cursor |