local proxy-key testing via x-dev-repo bypass (#629)
* 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.
This commit is contained in:
committed by
pullfrog[bot]
parent
8f36eca62a
commit
b0274e3265
@@ -22,6 +22,7 @@ import {
|
|||||||
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
||||||
import { apiFetch } from "./utils/apiFetch.ts";
|
import { apiFetch } from "./utils/apiFetch.ts";
|
||||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||||
|
import { isLocalApiUrl } from "./utils/apiUrl.ts";
|
||||||
import { resolveBody } from "./utils/body.ts";
|
import { resolveBody } from "./utils/body.ts";
|
||||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||||
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
|
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
|
||||||
@@ -280,18 +281,18 @@ function formatTransientErrorSummary(error: TransientError, owner: string): stri
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
async function mintProxyKey(ctx: {
|
||||||
|
oidcCredentials: OidcCredentials | null;
|
||||||
|
repo: { owner: string; name: string };
|
||||||
|
}): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
const headers = await buildProxyTokenHeaders(ctx);
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
if (!headers) return null;
|
||||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
||||||
|
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: "/api/proxy-token",
|
path: "/api/proxy-token",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${oidcToken}` },
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 402) {
|
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<Record<string, string> | 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: {
|
async function resolveProxyModel(ctx: {
|
||||||
payload: ResolvedPayload;
|
payload: ResolvedPayload;
|
||||||
oss: boolean;
|
oss: boolean;
|
||||||
plan: AccountPlan;
|
plan: AccountPlan;
|
||||||
proxyModel?: string | undefined;
|
proxyModel?: string | undefined;
|
||||||
oidcCredentials: OidcCredentials | null;
|
oidcCredentials: OidcCredentials | null;
|
||||||
|
repo: { owner: string; name: string };
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// env override = BYOK escape hatch, don't proxy
|
// env override = BYOK escape hatch, don't proxy
|
||||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
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;
|
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||||
if (!needsProxy) return;
|
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");
|
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
process.env.OPENROUTER_API_KEY = key;
|
process.env.OPENROUTER_API_KEY = key;
|
||||||
@@ -573,6 +609,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
plan: runContext.plan,
|
plan: runContext.plan,
|
||||||
proxyModel: runContext.proxyModel,
|
proxyModel: runContext.proxyModel,
|
||||||
oidcCredentials,
|
oidcCredentials,
|
||||||
|
repo: runContext.repo,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BillingError) {
|
if (error instanceof BillingError) {
|
||||||
|
|||||||
@@ -25,3 +25,18 @@ export function getApiUrl(): string {
|
|||||||
log.debug(`resolved API_URL: ${raw}`);
|
log.debug(`resolved API_URL: ${raw}`);
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user