fix: audit batch — MCP timeouts, entryPost, vip_audit 404s, and 6 more (#824)

* fix: 9 unaddressed log-audit / run-audit findings

Co-authored-by: Cursor <cursoragent@cursor.com>

#815 entryPost stdlib-only imports; #823 MCP timeoutMs on checkout_pr/shell;
#816 FREE_FALLBACK → opencode/big-pickle; #822 chunk GraphQL nodes ≤100;
#817/#821 vip_audit 404 skip paths; #813 longer serializable retries;
#818 run-context handler-entered log; #805 audit severity template.

* fix: update footer test for big-pickle fallback slug

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: anneal round 1 — ghaCore getState casing, post-hook timeout

Match @actions/core STATE_ key semantics (no uppercasing), cap
postApiFetch at 30s, trim serializable retries to stay under GitHub's
10s webhook window, log Clerk failures in getUserTokenByGithubLogin.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert: drop run-context handler log (#818 deferred)

The #692 client-side fix is already on main; residual SyntaxError hits
are old action pins. Per-request log added noise without fixing anything.

Co-authored-by: Cursor <cursoragent@cursor.com>

* document per-issue Closes syntax for audit PRs

GitHub only auto-closes the first issue when numbers are comma-separated;
/audits and AGENTS.md now require Closes before each issue number.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: anneal round 2 — outreach privacy, alert resilience, vertex cleanup

Filter private repos from VIP authority output, harden console alert
lines against DB failures, drop spoofable changesets body check, and
unset GOOGLE_APPLICATION_CREDENTIALS after vertex credential cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop codexHome re-export of detectCodexRefresh

Import detectCodexRefresh directly from codexRefreshDetect.ts everywhere;
rename the unit test file to match. codexHome.ts stays install-only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: drop deprecated minimax-m2.5-free; add paid minimax-m2.5

Remove the deprecated free MiniMax promo from the catalog, docs, and
tests. BYOK fallback and picker copy stay on opencode/big-pickle. Add
opencode/minimax-m2.5 and openrouter/minimax-m2.5 for Zen BYOK and
Router. Pin #816 regressions with freeFallbackCatalog and runErrorRenderer
unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: hidden minimax-m2.5-free fallback for stored slugs

Re-add opencode/minimax-m2.5-free as a hidden fallback alias to big-pickle
so repos with the legacy slug still resolve as free. Drop live Zen API
experiment tests in freeFallbackCatalog.test.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
David Blass
2026-05-22 16:48:25 +00:00
committed by pullfrog[bot]
parent e65dbe420c
commit d3b5340583
15 changed files with 220 additions and 48 deletions
+19
View File
@@ -1,6 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveCliModel } from "../models.ts";
import { FREE_FALLBACK_SLUG, selectFallbackModelIfNeeded } from "./byokFallback.ts";
describe("FREE_FALLBACK_SLUG", () => {
it("resolves in the curated catalog", () => {
expect(resolveCliModel(FREE_FALLBACK_SLUG)).toBe("opencode/big-pickle");
});
it("is opencode/big-pickle", () => {
expect(FREE_FALLBACK_SLUG).toBe("opencode/big-pickle");
});
});
describe("selectFallbackModelIfNeeded", () => {
const originalEnv = { ...process.env };
const KEYS = [
@@ -91,6 +102,14 @@ describe("selectFallbackModelIfNeeded", () => {
expect(result.fallback).toBe(false);
});
it("does not fall back when stored minimax-m2.5-free resolves to big-pickle", () => {
const result = selectFallbackModelIfNeeded({
resolvedModel: resolveCliModel("opencode/minimax-m2.5-free"),
proxyModel: undefined,
});
expect(result.fallback).toBe(false);
});
it("treats empty-string env vars as missing (matches GH Actions secret-not-found behavior)", () => {
process.env.ANTHROPIC_API_KEY = "";
const result = selectFallbackModelIfNeeded({
-34
View File
@@ -138,37 +138,3 @@ function parseCodexBlob(raw: string): CodexAuthBlob | null {
...(typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}),
};
}
/** convert an on-disk OpenCode auth.json back to the Codex CLI shape so the
* post-hook can write it to the Pullfrog secret store. returns null when the
* file's `openai` entry is missing, has the wrong type, or hasn't actually
* refreshed (refresh token unchanged from `originalRefresh`). */
export function detectCodexRefresh(params: {
authFileContent: string;
originalRefresh: string;
}): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(params.authFileContent);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const oauth = (parsed as Record<string, unknown>).openai;
if (!oauth || typeof oauth !== "object") return null;
const o = oauth as Record<string, unknown>;
if (o.type !== "oauth") return null;
if (typeof o.refresh !== "string" || typeof o.access !== "string") return null;
if (o.refresh === params.originalRefresh) return null;
const codexShape: CodexAuthBlob = {
auth_mode: "chatgpt",
tokens: {
access_token: o.access,
refresh_token: o.refresh,
...(typeof o.accountId === "string" ? { account_id: o.accountId } : {}),
},
last_refresh: new Date().toISOString(),
};
return `${JSON.stringify(codexShape, null, 2)}\n`;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { detectCodexRefresh } from "./codexHome.ts";
import { detectCodexRefresh } from "./codexRefreshDetect.ts";
// installCodexAuth touches the filesystem (mkdir + writeFile) — leaving it
// untested here per AGENTS.md guidance ("be highly dubious of any test that
+35
View File
@@ -0,0 +1,35 @@
/** Convert an on-disk OpenCode auth.json back to the Codex CLI shape so the
* post-hook can write it to the Pullfrog secret store. Returns null when the
* file's `openai` entry is missing, has the wrong type, or hasn't actually
* refreshed (refresh token unchanged from `originalRefresh`). Lives in its
* own module so `entryPost.ts` can import it without pulling in `codexHome.ts`
* (which imports `./cli.ts` and node fs helpers). */
export function detectCodexRefresh(params: {
authFileContent: string;
originalRefresh: string;
}): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(params.authFileContent);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const oauth = (parsed as Record<string, unknown>).openai;
if (!oauth || typeof oauth !== "object") return null;
const o = oauth as Record<string, unknown>;
if (o.type !== "oauth") return null;
if (typeof o.refresh !== "string" || typeof o.access !== "string") return null;
if (o.refresh === params.originalRefresh) return null;
const codexShape = {
auth_mode: "chatgpt",
tokens: {
access_token: o.access,
refresh_token: o.refresh,
...(typeof o.accountId === "string" ? { account_id: o.accountId } : {}),
},
last_refresh: new Date().toISOString(),
};
return `${JSON.stringify(codexShape, null, 2)}\n`;
}
+13
View File
@@ -0,0 +1,13 @@
/** stdlib-only GitHub Actions helpers for entryPost.ts (no node_modules). */
export function getState(name: string): string {
return process.env[`STATE_${name}`] ?? "";
}
export function info(message: string): void {
console.log(message);
}
export function warning(message: string): void {
console.log(`::warning::${message}`);
}
+51
View File
@@ -0,0 +1,51 @@
/** stdlib-only Pullfrog API fetch for entryPost.ts (no node_modules). */
type PostApiFetchOptions = {
path: string;
method?: string | undefined;
headers?: Record<string, string> | undefined;
body?: string | undefined;
};
function getApiUrl(): string {
return process.env.API_URL || "https://pullfrog.com";
}
export async function postApiFetch(options: PostApiFetchOptions): Promise<Response> {
const url = new URL(options.path, getApiUrl());
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers: Record<string, string> = {
...options.headers,
};
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
if (!options.body) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === "content-type") delete headers[key];
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30_000);
try {
const init: RequestInit = {
method: options.method ?? "GET",
headers,
signal: controller.signal,
};
if (options.body) init.body = options.body;
return await fetch(url, init);
} finally {
clearTimeout(timeoutId);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { renderRunError } from "./runErrorRenderer.ts";
const repo = { owner: "acme", name: "widget" };
describe("renderRunError ProviderModelNotFoundError (#816)", () => {
const staleFreeRaw =
'ProviderModelNotFoundError: {"providerID":"opencode","modelID":"retired-free-model","suggestions":["deepseek-v4-flash-free"]}';
const bigPickleRaw =
'ProviderModelNotFoundError: {"providerID":"opencode","modelID":"big-pickle","suggestions":[]}';
it("renders actionable copy for a stale free fallback model id", () => {
const result = renderRunError({
errorMessage: staleFreeRaw,
repo,
agentDiagnostic: undefined,
});
expect(result.summary).toContain("Pullfrog's free fallback model is no longer available");
expect(result.summary).toContain("`acme/widget`");
expect(result.summary).toContain("retired-free-model");
expect(result.comment).toBe(result.summary);
});
it("renders the same classifier when big-pickle is missing from opencode catalog", () => {
const result = renderRunError({
errorMessage: bigPickleRaw,
repo,
agentDiagnostic: undefined,
});
expect(result.summary).toContain("Pullfrog's free fallback model is no longer available");
expect(result.summary).toContain("big-pickle");
});
it("does not misclassify unrelated failures as fallback-catalog errors", () => {
const result = renderRunError({
errorMessage: "activity timeout after 900s",
repo,
agentDiagnostic: undefined,
});
expect(result.summary).not.toContain("free fallback model is no longer available");
});
});
+26
View File
@@ -38,6 +38,23 @@ export type RenderedRunError = {
comment: string;
};
function isProviderModelNotFoundError(message: string): boolean {
return message.includes("ProviderModelNotFoundError");
}
function formatProviderModelNotFoundSummary(input: {
owner: string;
name: string;
raw: string;
}): string {
return (
`Pullfrog's free fallback model is no longer available in OpenCode's catalog. ` +
`Add an API key for your configured model in the Pullfrog console for \`${input.owner}/${input.name}\`, ` +
`or contact support if this persists.\n\n` +
`\`\`\`\n${input.raw}\n\`\`\``
);
}
export function renderRunError(input: {
errorMessage: string;
repo: { owner: string; name: string };
@@ -83,6 +100,15 @@ export function renderRunError(input: {
return { summary: apiKeyErrorSummary, comment: apiKeyErrorSummary };
}
if (isProviderModelNotFoundError(input.errorMessage)) {
const body = formatProviderModelNotFoundSummary({
owner: input.repo.owner,
name: input.repo.name,
raw: input.errorMessage,
});
return { summary: body, comment: body };
}
if (hangBody) {
return {
summary: `### ❌ Pullfrog failed\n\n${hangBody}`,
+1
View File
@@ -71,6 +71,7 @@ export function materializeVertexCredentials(params: {
export function cleanupVertexCredentials(credentials: VertexCredentials | undefined): void {
if (!credentials) return;
rmSync(credentials.secretDir, { recursive: true, force: true });
delete process.env[GOOGLE_APPLICATION_CREDENTIALS_ENV];
}
export function applyClaudeVertexEnv(env: Record<string, string | undefined>): void {