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:
committed by
pullfrog[bot]
parent
e65dbe420c
commit
d3b5340583
+7
-9
@@ -24,11 +24,14 @@
|
||||
// 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
|
||||
// `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 * as core from "@actions/core";
|
||||
import { apiFetch } from "./utils/apiFetch.ts";
|
||||
import { detectCodexRefresh } from "./utils/codexHome.ts";
|
||||
import { detectCodexRefresh } from "./utils/codexRefreshDetect.ts";
|
||||
import * as core from "./utils/ghaCore.ts";
|
||||
import { postApiFetch } from "./utils/postApiFetch.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = core.getState("codex_writeback");
|
||||
@@ -72,11 +75,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
// route through apiFetch so the Vercel preview-deployment SSO gate gets
|
||||
// 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({
|
||||
const response = await postApiFetch({
|
||||
path: "/api/runtime/secret",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -97,6 +96,5 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// never throw — post-hook failure must not fail the workflow
|
||||
core.warning(`codex post-hook: unexpected error — ${err}`);
|
||||
});
|
||||
|
||||
+4
-2
@@ -791,12 +791,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
|
||||
return tool({
|
||||
name: "checkout_pr",
|
||||
timeoutMs: 600_000,
|
||||
description:
|
||||
"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. " +
|
||||
"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. " +
|
||||
"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.",
|
||||
"Large repos can take several minutes — wait for the call to finish; do not treat a slow response as failure. " +
|
||||
"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,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const inFlight = inFlightCheckouts.get(pull_number);
|
||||
|
||||
@@ -232,6 +232,7 @@ function isGitCommand(command: string): boolean {
|
||||
export function ShellTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "shell",
|
||||
timeoutMs: 120_000,
|
||||
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
||||
|
||||
Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("getModelEnvVars", () => {
|
||||
|
||||
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -109,6 +110,10 @@ describe("resolveCliModel", () => {
|
||||
expect(resolveCliModel("opencode/gpt-codex")).toBe("opencode/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", () => {
|
||||
|
||||
@@ -343,6 +343,11 @@ export const providers = {
|
||||
resolve: "opencode/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": {
|
||||
displayName: "GPT Nano",
|
||||
resolve: "opencode/gpt-5-nano",
|
||||
@@ -361,6 +366,7 @@ export const providers = {
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
fallback: "opencode/big-pickle",
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -499,6 +505,11 @@ export const providers = {
|
||||
resolve: "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>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { 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
|
||||
* state that the post-hook would consume, which is the genuine integration
|
||||
* 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
|
||||
* `.env`. CI: provisioned as `secrets.CODEX_AUTH_JSON` and forwarded by the
|
||||
@@ -99,6 +99,7 @@ export const test: TestRunnerOptions = {
|
||||
},
|
||||
coverage: [
|
||||
"action/utils/codexHome.ts",
|
||||
"action/utils/codexRefreshDetect.ts",
|
||||
"action/entryPost.ts",
|
||||
"action/agents/{opencode,opencode_v2}.ts",
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user