8dff91ac49
* 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.
152 lines
4.0 KiB
TypeScript
152 lines
4.0 KiB
TypeScript
import type { PushPermission, ShellPermission } from "../external.ts";
|
|
import { apiFetch } from "./apiFetch.ts";
|
|
import type { RepoContext } from "./github.ts";
|
|
|
|
export interface Mode {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
prompt: string;
|
|
}
|
|
|
|
/**
|
|
* server-parsed TOC entry for `Repo.learnings`. depth is 1-6 (h1-h6),
|
|
* line numbers are 1-indexed against the raw body. computed by
|
|
* `parseLearningsHeadings` in `utils/learningsToc.ts` (server side) and
|
|
* shipped over the run-context JSON boundary; the canonical declaration
|
|
* lives there. duplicated here because the action runtime can't reach
|
|
* across into the proprietary root-level codebase, and the JSON wire
|
|
* means typecheck can't enforce shape equality across both sides.
|
|
*/
|
|
export interface LearningsHeading {
|
|
depth: 1 | 2 | 3 | 4 | 5 | 6;
|
|
title: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
}
|
|
|
|
export interface RepoSettings {
|
|
model: string | null;
|
|
modes: Mode[];
|
|
setupScript: string | null;
|
|
postCheckoutScript: string | null;
|
|
prepushScript: string | null;
|
|
stopScript: string | null;
|
|
push: PushPermission;
|
|
shell: ShellPermission;
|
|
prApproveEnabled: boolean;
|
|
modeInstructions: Record<string, string>;
|
|
learnings: string | null;
|
|
learningsHeadings: LearningsHeading[];
|
|
envAllowlist: string | null;
|
|
}
|
|
|
|
/**
|
|
* Account-level billing plan. Orthogonal to repo-level OSS status. Mirrors
|
|
* the server's `AccountPlan` in `utils/billing.ts`. `"none"` = free tier,
|
|
* `"payg"` = card on file / pay-as-you-go.
|
|
*/
|
|
export type AccountPlan = "none" | "payg";
|
|
|
|
export interface RunContext {
|
|
settings: RepoSettings;
|
|
apiToken: string;
|
|
oss: boolean;
|
|
plan: AccountPlan;
|
|
proxyModel?: string | undefined;
|
|
dbSecrets?: Record<string, string> | undefined;
|
|
}
|
|
|
|
const defaultSettings: RepoSettings = {
|
|
model: null,
|
|
modes: [],
|
|
setupScript: null,
|
|
postCheckoutScript: null,
|
|
prepushScript: null,
|
|
stopScript: null,
|
|
push: "restricted",
|
|
shell: "restricted",
|
|
prApproveEnabled: false,
|
|
modeInstructions: {},
|
|
learnings: null,
|
|
learningsHeadings: [],
|
|
envAllowlist: null,
|
|
};
|
|
|
|
const defaultRunContext: RunContext = {
|
|
settings: defaultSettings,
|
|
apiToken: "",
|
|
oss: false,
|
|
plan: "none",
|
|
};
|
|
|
|
/**
|
|
* fetch run context from Pullfrog API
|
|
* returns settings + API token for subsequent calls
|
|
* returns defaults if fetch fails
|
|
*/
|
|
export async function fetchRunContext(params: {
|
|
token: string;
|
|
repoContext: RepoContext;
|
|
oidcToken?: string | undefined;
|
|
}): Promise<RunContext> {
|
|
const timeoutMs = 30000;
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${params.token}`,
|
|
};
|
|
if (params.oidcToken) {
|
|
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
|
|
}
|
|
|
|
const response = await apiFetch({
|
|
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
return defaultRunContext;
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
settings: RepoSettings | null;
|
|
apiToken: string;
|
|
oss?: boolean;
|
|
plan?: AccountPlan;
|
|
proxyModel?: string;
|
|
dbSecrets?: Record<string, string>;
|
|
} | null;
|
|
|
|
if (data === null) {
|
|
return defaultRunContext;
|
|
}
|
|
|
|
return {
|
|
settings: {
|
|
...defaultSettings,
|
|
...data.settings,
|
|
modes: data.settings?.modes ?? [],
|
|
setupScript: data.settings?.setupScript ?? null,
|
|
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
|
prepushScript: data.settings?.prepushScript ?? null,
|
|
stopScript: data.settings?.stopScript ?? null,
|
|
learningsHeadings: data.settings?.learningsHeadings ?? [],
|
|
},
|
|
apiToken: data.apiToken,
|
|
oss: data.oss ?? false,
|
|
plan: data.plan ?? "none",
|
|
proxyModel: data.proxyModel,
|
|
dbSecrets: data.dbSecrets,
|
|
};
|
|
} catch {
|
|
clearTimeout(timeoutId);
|
|
return defaultRunContext;
|
|
}
|
|
}
|