From b0274e32652982639780a12885353d1c85a39908 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 8 May 2026 23:35:58 +0000 Subject: [PATCH] local proxy-key testing via x-dev-repo bypass (#629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * local proxy-key testing via x-dev-repo bypass `pnpm play` previously couldn't exercise the proxy/router/oss code path — `resolveProxyModel` early-exits without OIDC credentials, and `mintProxyKey` always sends an OIDC bearer to `/api/proxy-token`. since GitHub Actions OIDC only exists in real workflow runs, billing flows (auto-reload, balance gates, key rotation, OSS subsidy) had no local feedback loop. a server-side dev bypass already exists at `app/api/proxy-token/route.ts` that accepts an `x-dev-repo: owner/repo` header instead of an OIDC bearer when `NODE_ENV === "development"`. wire the action side so it sends that header when there are no OIDC credentials AND `API_URL` resolves to localhost (i.e. the developer is talking to their own `pnpm dev` server). production is unreachable through this path because vercel never sets `NODE_ENV=development`. document the affordance in `wiki/action-tests.md` so the next person doesn't have to re-discover it (the server bypass had been sitting there undocumented since the WIP billing rewrite). verified end-to-end: `PLAY_LOCAL=1 GITHUB_REPOSITORY=pullfrog/app API_URL=http://localhost:3100 pnpm play …` now logs `» proxy: dev bypass (x-dev-repo) for pullfrog/app` → `» proxy: router → openrouter/ anthropic/claude-opus-4.7` → `» model: …(proxy)`, mints a real OpenRouter key against the dev DB, and the agent runs through the proxy. * wiki: cross-reference dev proxy-key affordance from main/e2e/stripe action-tests.md already documents the localhost+x-dev-repo path; mention it from the natural discovery points so the next person finds it without spelunking through git history again: - main.md: resolveProxyModel row in the dependencies table notes the two auth paths (OIDC bearer in prod, x-dev-repo in dev). - e2e-testing.md: "When to use this" calls out the lighter-weight alternative for proxy-only changes. - stripe.md: new "Loop including the action" subsection in the Dev workflow section, alongside the existing dev-script and cron-endpoint loops. --- main.ts | 55 +++++++++++++++++++++++++++++++++++++++++-------- utils/apiUrl.ts | 15 ++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/main.ts b/main.ts index 3391b93..6bd587c 100644 --- a/main.ts +++ b/main.ts @@ -22,6 +22,7 @@ import { import { resolveAgent, resolveModel } from "./utils/agent.ts"; import { apiFetch } from "./utils/apiFetch.ts"; import { validateAgentApiKey } from "./utils/apiKeys.ts"; +import { isLocalApiUrl } from "./utils/apiUrl.ts"; import { resolveBody } from "./utils/body.ts"; import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts"; import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts"; @@ -280,18 +281,18 @@ function formatTransientErrorSummary(error: TransientError, owner: string): stri ].join("\n"); } -async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise { +async function mintProxyKey(ctx: { + oidcCredentials: OidcCredentials | null; + repo: { owner: string; name: string }; +}): Promise { try { - process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken; - const oidcToken = await core.getIDToken("pullfrog-api"); - delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + const headers = await buildProxyTokenHeaders(ctx); + if (!headers) return null; const response = await apiFetch({ path: "/api/proxy-token", method: "POST", - headers: { Authorization: `Bearer ${oidcToken}` }, + headers, }); if (response.status === 402) { @@ -337,12 +338,44 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise< } } +/** + * choose how to authenticate the `/api/proxy-token` request: + * + * - production: mint a fresh OIDC token via `core.getIDToken` and send as + * `Authorization: Bearer …` (the server verifies it cryptographically). + * - local dev (no OIDC + `API_URL` is localhost): send `x-dev-repo: + * owner/repo` instead. the server-side route only honors this header + * when `NODE_ENV === "development"`, so prod is never reachable through + * this branch even if the action is misconfigured. + * + * returns null when neither path is available — caller treats as soft skip. + */ +async function buildProxyTokenHeaders(ctx: { + oidcCredentials: OidcCredentials | null; + repo: { owner: string; name: string }; +}): Promise | null> { + if (ctx.oidcCredentials) { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken; + const oidcToken = await core.getIDToken("pullfrog-api"); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + return { Authorization: `Bearer ${oidcToken}` }; + } + if (isLocalApiUrl()) { + log.info(`» proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`); + return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` }; + } + return null; +} + async function resolveProxyModel(ctx: { payload: ResolvedPayload; oss: boolean; plan: AccountPlan; proxyModel?: string | undefined; oidcCredentials: OidcCredentials | null; + repo: { owner: string; name: string }; }): Promise { // env override = BYOK escape hatch, don't proxy if (process.env.PULLFROG_MODEL?.trim()) return; @@ -350,12 +383,15 @@ async function resolveProxyModel(ctx: { const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel; if (!needsProxy) return; - if (!ctx.oidcCredentials) { + // dev affordance: when talking to a localhost API, the server-side + // x-dev-repo bypass replaces OIDC verification, so a play run can + // exercise the proxy/router/oss path without GitHub Actions OIDC. + if (!ctx.oidcCredentials && !isLocalApiUrl()) { log.warning("» proxy requested but no OIDC credentials available — skipping"); return; } - const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); + const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo }); if (!key) return; process.env.OPENROUTER_API_KEY = key; @@ -573,6 +609,7 @@ export async function main(): Promise { plan: runContext.plan, proxyModel: runContext.proxyModel, oidcCredentials, + repo: runContext.repo, }); } catch (error) { if (error instanceof BillingError) { diff --git a/utils/apiUrl.ts b/utils/apiUrl.ts index 233fef8..ab93dbb 100644 --- a/utils/apiUrl.ts +++ b/utils/apiUrl.ts @@ -25,3 +25,18 @@ export function getApiUrl(): string { log.debug(`resolved API_URL: ${raw}`); return raw; } + +/** + * true when the action is configured to talk to a localhost API server (i.e. + * `pnpm dev` running on the developer's box). signals we can use dev-only + * affordances like the `x-dev-repo` proxy-token bypass — the corresponding + * server-side dev gates (`NODE_ENV === "development"`) ensure these paths + * never activate against prod regardless of what the action does. + */ +export function isLocalApiUrl(): boolean { + try { + return isLocalUrl(new URL(getApiUrl())); + } catch { + return false; + } +}