diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml
index f5ae018..808bda4 100644
--- a/.github/workflows/pullfrog.yml
+++ b/.github/workflows/pullfrog.yml
@@ -32,6 +32,8 @@ jobs:
with:
prompt: ${{ inputs.prompt }}
env:
+ API_URL: ${{ secrets.API_URL }}
+ VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
# add any additional keys your agent(s) need
# optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
diff --git a/README.md b/README.md
index edc80e8..ffe8977 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
diff --git a/entry b/entry
index 103c40f..d5c1fca 100755
--- a/entry
+++ b/entry
@@ -139417,15 +139417,43 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
);
// utils/apiUrl.ts
-function getApiUrl() {
- const url4 = process.env.API_URL || "https://pullfrog.com";
- log.debug(`resolved API_URL: ${url4}`);
- return url4;
+function isLocalUrl(url4) {
+ return url4.hostname === "localhost" || url4.hostname === "127.0.0.1";
}
-function getVercelBypassHeaders() {
- const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
- if (!secret) return {};
- return { "x-vercel-protection-bypass": secret };
+function getApiUrl() {
+ const raw = process.env.API_URL || "https://pullfrog.com";
+ const parsed2 = new URL(raw);
+ if (parsed2.protocol !== "https:" && !isLocalUrl(parsed2)) {
+ throw new Error(
+ `API_URL must use https:// (got ${parsed2.protocol}). only localhost is exempt.`
+ );
+ }
+ log.debug(`resolved API_URL: ${raw}`);
+ return raw;
+}
+
+// utils/apiFetch.ts
+async function apiFetch(options) {
+ const apiUrl = getApiUrl();
+ const url4 = new URL(options.path, apiUrl);
+ const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
+ if (bypassSecret) {
+ url4.searchParams.set("x-vercel-protection-bypass", bypassSecret);
+ }
+ const headers = {
+ ...options.headers
+ };
+ if (bypassSecret) {
+ headers["x-vercel-protection-bypass"] = bypassSecret;
+ }
+ log.debug(`api fetch: ${options.method ?? "GET"} ${url4.pathname}`);
+ const init = {
+ method: options.method ?? "GET",
+ headers
+ };
+ if (options.body) init.body = options.body;
+ if (options.signal) init.signal = options.signal;
+ return fetch(url4.toString(), init);
}
// utils/retry.ts
@@ -139465,37 +139493,26 @@ function isOIDCAvailable() {
}
async function acquireTokenViaOIDC(opts) {
const oidcToken = await core2.getIDToken("pullfrog-api");
- const apiUrl = getApiUrl();
- const params = new URLSearchParams();
const repos = [...opts?.repos ?? []];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) {
repos.push(targetRepo);
}
- if (repos.length) {
- params.set("repos", repos.join(","));
- }
- const queryString = params.toString() ? `?${params.toString()}` : "";
+ const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
const timeoutMs = 3e4;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
- const fetchOptions = {
+ const tokenResponse = await apiFetch({
+ path: `/api/github/installation-token${reposParam}`,
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
- "Content-Type": "application/json",
- ...getVercelBypassHeaders()
+ "Content-Type": "application/json"
},
+ body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
signal: controller.signal
- };
- if (opts?.permissions) {
- fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
- }
- const tokenResponse = await fetch(
- `${apiUrl}/api/github/installation-token${queryString}`,
- fetchOptions
- );
+ });
clearTimeout(timeoutId);
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
@@ -144006,13 +144023,12 @@ function UploadFileTool(ctx) {
const contentLength = buffer.length;
const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream";
- const apiUrl = getApiUrl();
- const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
+ const response = await apiFetch({
+ path: "/api/upload/signed-url",
method: "POST",
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
- "Content-Type": "application/json",
- ...getVercelBypassHeaders()
+ "Content-Type": "application/json"
},
body: JSON.stringify({
filename,
@@ -146718,23 +146734,18 @@ var defaultRunContext = {
apiToken: ""
};
async function fetchRunContext(params) {
- const apiUrl = getApiUrl();
const timeoutMs = 3e4;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
- const response = await fetch(
- `${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
- {
- method: "GET",
- headers: {
- Authorization: `Bearer ${params.token}`,
- "Content-Type": "application/json",
- ...getVercelBypassHeaders()
- },
- signal: controller.signal
- }
- );
+ const response = await apiFetch({
+ path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
+ headers: {
+ Authorization: `Bearer ${params.token}`,
+ "Content-Type": "application/json"
+ },
+ signal: controller.signal
+ });
clearTimeout(timeoutId);
if (!response.ok) {
return defaultRunContext;
diff --git a/get-installation-token/entry b/get-installation-token/entry
index 81b1507..ef66160 100755
--- a/get-installation-token/entry
+++ b/get-installation-token/entry
@@ -25681,15 +25681,43 @@ var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
// utils/apiUrl.ts
-function getApiUrl() {
- const url = process.env.API_URL || "https://pullfrog.com";
- log.debug(`resolved API_URL: ${url}`);
- return url;
+function isLocalUrl(url) {
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
}
-function getVercelBypassHeaders() {
- const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
- if (!secret) return {};
- return { "x-vercel-protection-bypass": secret };
+function getApiUrl() {
+ const raw = process.env.API_URL || "https://pullfrog.com";
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
+ throw new Error(
+ `API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
+ );
+ }
+ log.debug(`resolved API_URL: ${raw}`);
+ return raw;
+}
+
+// utils/apiFetch.ts
+async function apiFetch(options) {
+ const apiUrl = getApiUrl();
+ const url = new URL(options.path, apiUrl);
+ const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
+ if (bypassSecret) {
+ url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
+ }
+ const headers = {
+ ...options.headers
+ };
+ if (bypassSecret) {
+ headers["x-vercel-protection-bypass"] = bypassSecret;
+ }
+ log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
+ const init = {
+ method: options.method ?? "GET",
+ headers
+ };
+ if (options.body) init.body = options.body;
+ if (options.signal) init.signal = options.signal;
+ return fetch(url.toString(), init);
}
// utils/retry.ts
@@ -25729,37 +25757,26 @@ function isOIDCAvailable() {
}
async function acquireTokenViaOIDC(opts) {
const oidcToken = await core2.getIDToken("pullfrog-api");
- const apiUrl = getApiUrl();
- const params = new URLSearchParams();
const repos = [...opts?.repos ?? []];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) {
repos.push(targetRepo);
}
- if (repos.length) {
- params.set("repos", repos.join(","));
- }
- const queryString = params.toString() ? `?${params.toString()}` : "";
+ const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
const timeoutMs = 3e4;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
- const fetchOptions = {
+ const tokenResponse = await apiFetch({
+ path: `/api/github/installation-token${reposParam}`,
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
- "Content-Type": "application/json",
- ...getVercelBypassHeaders()
+ "Content-Type": "application/json"
},
+ body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
signal: controller.signal
- };
- if (opts?.permissions) {
- fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
- }
- const tokenResponse = await fetch(
- `${apiUrl}/api/github/installation-token${queryString}`,
- fetchOptions
- );
+ });
clearTimeout(timeoutId);
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
diff --git a/mcp/upload.ts b/mcp/upload.ts
index 6a76b24..4b1c94c 100644
--- a/mcp/upload.ts
+++ b/mcp/upload.ts
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type";
-import { getApiUrl, getVercelBypassHeaders } from "../utils/apiUrl.ts";
+import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) {
const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream";
- const apiUrl = getApiUrl();
-
- const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
+ const response = await apiFetch({
+ path: "/api/upload/signed-url",
method: "POST",
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json",
- ...getVercelBypassHeaders(),
},
body: JSON.stringify({
filename,
diff --git a/utils/apiFetch.ts b/utils/apiFetch.ts
new file mode 100644
index 0000000..b264491
--- /dev/null
+++ b/utils/apiFetch.ts
@@ -0,0 +1,50 @@
+import { getApiUrl } from "./apiUrl.ts";
+import { log } from "./cli.ts";
+
+type ApiFetchOptions = {
+ path: string;
+ method?: string | undefined;
+ headers?: Record | undefined;
+ body?: string | undefined;
+ signal?: AbortSignal | undefined;
+};
+
+/**
+ * fetch wrapper for hitting the Pullfrog API with Vercel deployment protection bypass.
+ *
+ * adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
+ * the server-side forwarding code uses query params, and the Vercel docs say both work,
+ * so we do both as belt-and-suspenders.
+ *
+ * the query param approach is the primary bypass mechanism (matches server-side forwarding).
+ * the header is added as a fallback.
+ */
+export async function apiFetch(options: ApiFetchOptions): Promise {
+ const apiUrl = getApiUrl();
+ const url = new URL(options.path, apiUrl);
+
+ const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
+ if (bypassSecret) {
+ url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
+ }
+
+ const headers: Record = {
+ ...options.headers,
+ };
+
+ // also add as header for belt-and-suspenders
+ if (bypassSecret) {
+ headers["x-vercel-protection-bypass"] = bypassSecret;
+ }
+
+ log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
+
+ const init: RequestInit = {
+ method: options.method ?? "GET",
+ headers,
+ };
+ if (options.body) init.body = options.body;
+ if (options.signal) init.signal = options.signal;
+
+ return fetch(url.toString(), init);
+}
diff --git a/utils/apiUrl.ts b/utils/apiUrl.ts
index 5988d17..233fef8 100644
--- a/utils/apiUrl.ts
+++ b/utils/apiUrl.ts
@@ -1,24 +1,27 @@
import { log } from "./cli.ts";
+function isLocalUrl(url: URL): boolean {
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
+}
+
/**
* resolve the Pullfrog API base URL.
*
* in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com.
* in local dev: API_URL=http://localhost:3000 (from .env).
+ *
+ * enforces https:// for non-local URLs to prevent cleartext credential transmission.
*/
export function getApiUrl(): string {
- const url = process.env.API_URL || "https://pullfrog.com";
- log.debug(`resolved API_URL: ${url}`);
- return url;
-}
+ const raw = process.env.API_URL || "https://pullfrog.com";
+ const parsed = new URL(raw);
-/**
- * returns headers needed to bypass Vercel deployment protection on preview deployments.
- * when VERCEL_AUTOMATION_BYPASS_SECRET is set (preview repos), includes the bypass header.
- * otherwise returns an empty object (production / local dev).
- */
-export function getVercelBypassHeaders(): Record {
- const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
- if (!secret) return {};
- return { "x-vercel-protection-bypass": secret };
+ if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
+ throw new Error(
+ `API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
+ );
+ }
+
+ log.debug(`resolved API_URL: ${raw}`);
+ return raw;
}
diff --git a/utils/github.ts b/utils/github.ts
index 71b8851..ded0116 100644
--- a/utils/github.ts
+++ b/utils/github.ts
@@ -2,7 +2,7 @@ import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling";
import { Octokit } from "@octokit/rest";
-import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
+import { apiFetch } from "./apiFetch.ts";
import { retry } from "./retry.ts";
export interface InstallationToken {
@@ -85,41 +85,28 @@ type AcquireTokenOptions = {
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise {
const oidcToken = await core.getIDToken("pullfrog-api");
- const apiUrl = getApiUrl();
- const params = new URLSearchParams();
-
- // ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
const repos = [...(opts?.repos ?? [])];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) {
repos.push(targetRepo);
}
- if (repos.length) {
- params.set("repos", repos.join(","));
- }
- const queryString = params.toString() ? `?${params.toString()}` : "";
+ const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
- const fetchOptions: RequestInit = {
+ const tokenResponse = await apiFetch({
+ path: `/api/github/installation-token${reposParam}`,
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
- ...getVercelBypassHeaders(),
},
+ body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
signal: controller.signal,
- };
- if (opts?.permissions) {
- fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
- }
- const tokenResponse = await fetch(
- `${apiUrl}/api/github/installation-token${queryString}`,
- fetchOptions
- );
+ });
clearTimeout(timeoutId);
diff --git a/utils/runContext.ts b/utils/runContext.ts
index 046d795..b0d2ad5 100644
--- a/utils/runContext.ts
+++ b/utils/runContext.ts
@@ -1,5 +1,5 @@
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
-import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
+import { apiFetch } from "./apiFetch.ts";
import type { RepoContext } from "./github.ts";
export interface Mode {
@@ -52,24 +52,19 @@ export async function fetchRunContext(params: {
token: string;
repoContext: RepoContext;
}): Promise {
- const apiUrl = getApiUrl();
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
- const response = await fetch(
- `${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
- {
- method: "GET",
- headers: {
- Authorization: `Bearer ${params.token}`,
- "Content-Type": "application/json",
- ...getVercelBypassHeaders(),
- },
- signal: controller.signal,
- }
- );
+ const response = await apiFetch({
+ path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
+ headers: {
+ Authorization: `Bearer ${params.token}`,
+ "Content-Type": "application/json",
+ },
+ signal: controller.signal,
+ });
clearTimeout(timeoutId);
diff --git a/utils/secrets.ts b/utils/secrets.ts
index 4f0ff52..a7bb2e3 100644
--- a/utils/secrets.ts
+++ b/utils/secrets.ts
@@ -3,7 +3,6 @@
* Redacts actual secret values rather than using pattern matching
*/
-import { agentsManifest } from "../external.ts";
import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names
@@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record