Files
shockbot/utils/runContext.ts
T
Colin McDonnell 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.
2026-05-20 02:10:29 +00:00

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;
}
}