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
+7 -9
View File
@@ -24,11 +24,14 @@
// saved). Best-effort: failures are logged but never throw — the workflow // saved). Best-effort: failures are logged but never throw — the workflow
// is already done, and a missed refresh write-back means the user re-runs // is already done, and a missed refresh write-back means the user re-runs
// `pullfrog auth codex` next time the chain breaks. // `pullfrog auth codex` next time the chain breaks.
//
// Imports here MUST stay stdlib-only — GHA runs this file directly from the
// checked-out action repo, which has no node_modules for sha-pinned consumers.
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import * as core from "@actions/core"; import { detectCodexRefresh } from "./utils/codexRefreshDetect.ts";
import { apiFetch } from "./utils/apiFetch.ts"; import * as core from "./utils/ghaCore.ts";
import { detectCodexRefresh } from "./utils/codexHome.ts"; import { postApiFetch } from "./utils/postApiFetch.ts";
async function main(): Promise<void> { async function main(): Promise<void> {
const raw = core.getState("codex_writeback"); const raw = core.getState("codex_writeback");
@@ -72,11 +75,7 @@ async function main(): Promise<void> {
} }
try { try {
// route through apiFetch so the Vercel preview-deployment SSO gate gets const response = await postApiFetch({
// the `x-vercel-protection-bypass` header/query (raw fetch silently 401s
// against preview envs — production is unaffected but every preview-run
// refresh would be lost). see action/utils/apiFetch.ts.
const response = await apiFetch({
path: "/api/runtime/secret", path: "/api/runtime/secret",
method: "PUT", method: "PUT",
headers: { headers: {
@@ -97,6 +96,5 @@ async function main(): Promise<void> {
} }
main().catch((err) => { main().catch((err) => {
// never throw — post-hook failure must not fail the workflow
core.warning(`codex post-hook: unexpected error — ${err}`); core.warning(`codex post-hook: unexpected error — ${err}`);
}); });
+4 -2
View File
@@ -791,12 +791,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
return tool({ return tool({
name: "checkout_pr", name: "checkout_pr",
timeoutMs: 600_000,
description: description:
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " + "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
"Returns diffPath pointing to the formatted diff file. " + "Returns diffPath pointing to the formatted diff file. " +
"Example: `checkout_pr({ pull_number: 1234 })`. " + "Example: `checkout_pr({ pull_number: 1234 })`. " +
"Transient fetch timeouts are common — retry the same call up to a few times before treating the failure as terminal. " + "Large repos can take several minutes — wait for the call to finish; do not treat a slow response as failure. " +
"If the error mentions `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, that's a stale lock from a prior timed-out fetch — remove it via the shell tool (`rm -f .git/shallow.lock .git/index.lock`) and retry.", "If you see `MCP error -32001: Request timed out`, retry the same call without touching git lock files first — that error is a client-side abort. " +
"If the retry then reports `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, remove those lock files via the shell tool and retry again.",
parameters: CheckoutPr, parameters: CheckoutPr,
execute: execute(async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const inFlight = inFlightCheckouts.get(pull_number); const inFlight = inFlightCheckouts.get(pull_number);
+1
View File
@@ -232,6 +232,7 @@ function isGitCommand(command: string): boolean {
export function ShellTool(ctx: ToolContext) { export function ShellTool(ctx: ToolContext) {
return tool({ return tool({
name: "shell", name: "shell",
timeoutMs: 120_000,
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets. description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`. Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
+5
View File
@@ -64,6 +64,7 @@ describe("getModelEnvVars", () => {
it("still requires OPENCODE_API_KEY for non-free opencode models", () => { it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]); expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
expect(getModelEnvVars("opencode/minimax-m2.5")).toEqual(["OPENCODE_API_KEY"]);
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual(["OPENCODE_API_KEY"]); expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual(["OPENCODE_API_KEY"]);
}); });
}); });
@@ -109,6 +110,10 @@ describe("resolveCliModel", () => {
expect(resolveCliModel("opencode/gpt-codex")).toBe("opencode/gpt-5.5"); expect(resolveCliModel("opencode/gpt-codex")).toBe("opencode/gpt-5.5");
expect(resolveCliModel("openrouter/gpt-codex")).toBe("openrouter/openai/gpt-5.5"); expect(resolveCliModel("openrouter/gpt-codex")).toBe("openrouter/openai/gpt-5.5");
}); });
it("walks fallback chain for hidden deprecated minimax-m2.5-free", () => {
expect(resolveCliModel("opencode/minimax-m2.5-free")).toBe("opencode/big-pickle");
});
}); });
describe("resolveDisplayAlias", () => { describe("resolveDisplayAlias", () => {
+11
View File
@@ -343,6 +343,11 @@ export const providers = {
resolve: "opencode/kimi-k2.6", resolve: "opencode/kimi-k2.6",
openRouterResolve: "openrouter/moonshotai/kimi-k2.6", openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
}, },
"minimax-m2.5": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5",
openRouterResolve: "openrouter/minimax/minimax-m2.5",
},
"gpt-5-nano": { "gpt-5-nano": {
displayName: "GPT Nano", displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano", resolve: "opencode/gpt-5-nano",
@@ -361,6 +366,7 @@ export const providers = {
envVars: [], envVars: [],
isFree: true, isFree: true,
fallback: "opencode/big-pickle", fallback: "opencode/big-pickle",
hidden: true,
}, },
}, },
}), }),
@@ -499,6 +505,11 @@ export const providers = {
resolve: "openrouter/moonshotai/kimi-k2.6", resolve: "openrouter/moonshotai/kimi-k2.6",
openRouterResolve: "openrouter/moonshotai/kimi-k2.6", openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
}, },
"minimax-m2.5": {
displayName: "MiniMax M2.5",
resolve: "openrouter/minimax/minimax-m2.5",
openRouterResolve: "openrouter/minimax/minimax-m2.5",
},
}, },
}), }),
} satisfies Record<string, ProviderConfig>; } satisfies Record<string, ProviderConfig>;
+3 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { detectCodexRefresh } from "../../utils/codexHome.ts"; import { detectCodexRefresh } from "../../utils/codexRefreshDetect.ts";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts"; import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture } from "../utils.ts"; import { defineFixture } from "../utils.ts";
@@ -24,7 +24,7 @@ import { defineFixture } from "../utils.ts";
* invoked by `pnpm runtest`. instead, this test asserts the on-disk auth.json * invoked by `pnpm runtest`. instead, this test asserts the on-disk auth.json
* state that the post-hook would consume, which is the genuine integration * state that the post-hook would consume, which is the genuine integration
* boundary (everything past `detectCodexRefresh` is a single fetch + unit- * boundary (everything past `detectCodexRefresh` is a single fetch + unit-
* tested in codexHome.test.ts). * tested in codexRefreshDetect.test.ts).
* *
* requires `CODEX_AUTH_JSON` in the environment. dev-local: put it in * requires `CODEX_AUTH_JSON` in the environment. dev-local: put it in
* `.env`. CI: provisioned as `secrets.CODEX_AUTH_JSON` and forwarded by the * `.env`. CI: provisioned as `secrets.CODEX_AUTH_JSON` and forwarded by the
@@ -99,6 +99,7 @@ export const test: TestRunnerOptions = {
}, },
coverage: [ coverage: [
"action/utils/codexHome.ts", "action/utils/codexHome.ts",
"action/utils/codexRefreshDetect.ts",
"action/entryPost.ts", "action/entryPost.ts",
"action/agents/{opencode,opencode_v2}.ts", "action/agents/{opencode,opencode_v2}.ts",
], ],
+19
View File
@@ -1,6 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveCliModel } from "../models.ts";
import { FREE_FALLBACK_SLUG, selectFallbackModelIfNeeded } from "./byokFallback.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", () => { describe("selectFallbackModelIfNeeded", () => {
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
const KEYS = [ const KEYS = [
@@ -91,6 +102,14 @@ describe("selectFallbackModelIfNeeded", () => {
expect(result.fallback).toBe(false); 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)", () => { it("treats empty-string env vars as missing (matches GH Actions secret-not-found behavior)", () => {
process.env.ANTHROPIC_API_KEY = ""; process.env.ANTHROPIC_API_KEY = "";
const result = selectFallbackModelIfNeeded({ 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 } : {}), ...(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 { describe, expect, it } from "vitest";
import { detectCodexRefresh } from "./codexHome.ts"; import { detectCodexRefresh } from "./codexRefreshDetect.ts";
// installCodexAuth touches the filesystem (mkdir + writeFile) — leaving it // installCodexAuth touches the filesystem (mkdir + writeFile) — leaving it
// untested here per AGENTS.md guidance ("be highly dubious of any test that // 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; 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: { export function renderRunError(input: {
errorMessage: string; errorMessage: string;
repo: { owner: string; name: string }; repo: { owner: string; name: string };
@@ -83,6 +100,15 @@ export function renderRunError(input: {
return { summary: apiKeyErrorSummary, comment: apiKeyErrorSummary }; 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) { if (hangBody) {
return { return {
summary: `### ❌ Pullfrog failed\n\n${hangBody}`, summary: `### ❌ Pullfrog failed\n\n${hangBody}`,
+1
View File
@@ -71,6 +71,7 @@ export function materializeVertexCredentials(params: {
export function cleanupVertexCredentials(credentials: VertexCredentials | undefined): void { export function cleanupVertexCredentials(credentials: VertexCredentials | undefined): void {
if (!credentials) return; if (!credentials) return;
rmSync(credentials.secretDir, { recursive: true, force: true }); rmSync(credentials.secretDir, { recursive: true, force: true });
delete process.env[GOOGLE_APPLICATION_CREDENTIALS_ENV];
} }
export function applyClaudeVertexEnv(env: Record<string, string | undefined>): void { export function applyClaudeVertexEnv(env: Record<string, string | undefined>): void {