From 71d4296d06d4901af5105268e339f6c2604dd466 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:44:03 +0000 Subject: [PATCH] Wire safe-fetch into forge fetches behind SHIP_SAFE_FETCH (shadow by default) Adds src/forge-egress.ts: production pinned fetch (undici Agent whose connect.lookup returns the validated address), operator allow-list (SHIP_SAFE_FETCH_ALLOW) for self-hosted forges, and a forge fetch used as the default in git.ts and forge-state.ts. Default is shadow: the request is untouched and what enforcement would refuse is logged once per host and reason. SHIP_SAFE_FETCH=on enforces and validates every redirect hop; =off disables the check entirely. Tested on a real loopback socket (refused by default, reachable when explicitly allowed, connection pinned against a rebinding resolver). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VqsBNqvaWezf1DwQrAnVgX --- src/forge-egress.test.ts | 210 ++++++++++++++++++++++++++++++++++ src/forge-egress.ts | 236 +++++++++++++++++++++++++++++++++++++++ src/forge-state.ts | 3 +- src/git.ts | 31 ++--- 4 files changed, 464 insertions(+), 16 deletions(-) create mode 100644 src/forge-egress.test.ts create mode 100644 src/forge-egress.ts diff --git a/src/forge-egress.test.ts b/src/forge-egress.test.ts new file mode 100644 index 0000000..e0f4568 --- /dev/null +++ b/src/forge-egress.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import { createForgeFetch, decideEgress, egressConfig, EgressDenied, parseAllowList, pinnedFetch, resetShadowLog, type EgressConfig } from "./forge-egress.js"; +import { retrieveUntrusted } from "./safe-fetch.js"; +import { findOpenPullRequest, parseRepoUrl } from "./git.js"; + +const enforce = (allow = ""): EgressConfig => ({ mode: "enforce", allow: parseAllowList(allow) }); +const noResolve = async (): Promise => { + throw new Error("resolver must not be called"); +}; + +describe("forge-egress config", () => { + it("defaults to shadow; on enforces; off disables", () => { + assert.equal(egressConfig({}).mode, "shadow"); + assert.equal(egressConfig({ SHIP_SAFE_FETCH: "on" }).mode, "enforce"); + assert.equal(egressConfig({ SHIP_SAFE_FETCH: "off" }).mode, "off"); + assert.equal(egressConfig({ SHIP_SAFE_FETCH: "banana" }).mode, "shadow"); + }); + it("parses the allow-list with optional ports and brackets", () => { + assert.deepEqual(parseAllowList(" Forgejo , git.lan:3000,[::1]:8080,,"), [ + { host: "forgejo" }, + { host: "git.lan", port: 3000 }, + { host: "::1", port: 8080 }, + ]); + }); +}); + +describe("decideEgress", () => { + it("refuses private and metadata literals by default", async () => { + for (const u of ["https://169.254.169.254/x", "https://10.0.0.5/x", "https://127.0.0.1/x", "https://localhost/x", "http://forge.example.com/x"]) { + assert.equal((await decideEgress(u, enforce(), async () => ["93.184.216.34"])).allowed, false, u); + } + }); + it("allows a public https forge and pins its address", async () => { + const d = await decideEgress("https://git.example.com/a/b", enforce(), async () => ["93.184.216.34"]); + assert.equal(d.allowed, true); + if (d.allowed) assert.equal(d.pin.address, "93.184.216.34"); + }); + it("allow-list admits a private single-label http forge by name", async () => { + const d = await decideEgress("http://forgejo:3000/a/b", enforce("forgejo:3000"), async () => ["10.0.0.7"]); + assert.equal(d.allowed, true); + const wrongPort = await decideEgress("http://forgejo:9999/a/b", enforce("forgejo:3000"), async () => ["10.0.0.7"]); + assert.equal(wrongPort.allowed, false); + }); + it("allow-list never admits metadata or link-local answers", async () => { + const d = await decideEgress("https://git.lan/a/b", enforce("git.lan"), async () => ["169.254.169.254"]); + assert.equal(d.allowed, false); + assert.equal((await decideEgress("http://169.254.169.254/", enforce("169.254.169.254"), noResolve)).allowed, false); + }); +}); + +describe("forge fetch on a real local socket", () => { + let server: Server; + let port: number; + const seen: Array<{ host: string | undefined; auth: string | undefined; url: string | undefined }> = []; + before(async () => { + server = createServer((req, res) => { + seen.push({ host: req.headers.host, auth: req.headers.authorization, url: req.url }); + if (req.url === "/redirect-away") { + res.writeHead(302, { location: "http://127.0.0.1:1/elsewhere" }); + return res.end(); + } + if (req.url === "/redirect-self") { + res.writeHead(302, { location: "/final" }); + return res.end(); + } + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"ok":true}'); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + port = (server.address() as AddressInfo).port; + }); + after(() => new Promise((r) => server.close(() => r()))); + + it("refuses the loopback server under the default policy and never connects", async () => { + seen.length = 0; + const f = createForgeFetch({ config: () => enforce() }); + await assert.rejects(f(`http://127.0.0.1:${port}/api/v1/x`, { headers: { authorization: "token secret" } }), EgressDenied); + assert.equal(seen.length, 0); + }); + + it("reaches it when the operator allows it explicitly (real pinned undici connection)", async () => { + seen.length = 0; + const f = createForgeFetch({ config: () => enforce(`127.0.0.1:${port}`) }); + const r = await f(`http://127.0.0.1:${port}/api/v1/x`, { headers: { authorization: "token secret" } }); + assert.equal(r.status, 200); + assert.deepEqual(await r.json(), { ok: true }); + assert.equal(seen[0]?.auth, "token secret"); + }); + + it("pins the connection: a name that resolves differently later still reaches the validated address, with the name as Host", async () => { + seen.length = 0; + let calls = 0; + // First answer is the loopback server; a rebinding resolver would answer 10.0.0.1 next. The pin means it is asked once. + const resolve = async () => (calls++ === 0 ? ["127.0.0.1"] : ["10.255.255.1"]); + const f = createForgeFetch({ config: () => enforce(`forge.test:${port}`), resolve }); + const r = await f(`http://forge.test:${port}/api/v1/x`); + assert.equal(r.status, 200); + assert.equal(calls, 1); + assert.equal(seen[0]?.host, `forge.test:${port}`); + }); + + it("pinnedFetch ignores the system resolver for an unresolvable name", async () => { + const r = await pinnedFetch(`http://does-not-resolve.invalid:${port}/p`, {}, { address: "127.0.0.1", family: 4, hostname: "does-not-resolve.invalid" }); + assert.equal(r.status, 200); + await r.text(); + }); + + it("validates a redirect hop and strips credentials off-origin", async () => { + seen.length = 0; + const f = createForgeFetch({ config: () => enforce(`127.0.0.1:${port}`) }); + await assert.rejects(f(`http://127.0.0.1:${port}/redirect-away`, { headers: { authorization: "token secret" } }), EgressDenied); + assert.equal(seen.length, 1, "the second hop was never connected"); + const ok = await f(`http://127.0.0.1:${port}/redirect-self`, { headers: { authorization: "token secret" } }); + assert.equal(ok.status, 200); + assert.equal(seen.at(-1)?.url, "/final"); + }); + + it("redirect:'error' still errors on a redirect", async () => { + const f = createForgeFetch({ config: () => enforce(`127.0.0.1:${port}`) }); + await assert.rejects(f(`http://127.0.0.1:${port}/redirect-self`, { redirect: "error" }), /redirect/); + }); + + it("retrieveUntrusted uses the production fetch against the real socket", async () => { + const deps = { resolve: async () => ["127.0.0.1"], fetch: pinnedFetch }; + const refused = await retrieveUntrusted(`https://127.0.0.1:${port}/x`, deps); + assert.equal(refused.ok, false); + const allowed = await retrieveUntrusted(`http://127.0.0.1:${port}/x`, { ...deps, allowPrivate: true, allowHttp: true, allowPorts: [port] }); + assert.equal(allowed.ok, true); + }); + + it("the git.ts call path goes through the guard when enforcing, using the default fetch", async () => { + const real = globalThis.fetch; + let reached = false; + globalThis.fetch = (async () => { + reached = true; + return new Response("[]"); + }) as typeof fetch; + const prev = { on: process.env.SHIP_SAFE_FETCH, allow: process.env.SHIP_SAFE_FETCH_ALLOW }; + try { + process.env.SHIP_SAFE_FETCH = "on"; + delete process.env.SHIP_SAFE_FETCH_ALLOW; + const ref = parseRepoUrl("http://169.254.169.254/owner/repo"); + await assert.rejects(findOpenPullRequest({ ref, token: "t", head: "h", owner: "o" }), EgressDenied); + assert.equal(reached, false); + } finally { + globalThis.fetch = real; + for (const [k, v] of [["SHIP_SAFE_FETCH", prev.on], ["SHIP_SAFE_FETCH_ALLOW", prev.allow]] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); +}); + +describe("shadow and off modes leave the request untouched", () => { + it("shadow: same call reaches the passthrough with identical arguments, and logs what it would refuse once", async () => { + resetShadowLog(); + const logs: string[] = []; + const calls: unknown[][] = []; + const f = createForgeFetch({ + config: () => ({ mode: "shadow", allow: [] }), + resolve: async () => ["10.0.0.9"], + passthrough: (async (...a: unknown[]) => { + calls.push(a); + return new Response("x"); + }) as typeof fetch, + log: (l) => logs.push(l), + }); + const init = { headers: { authorization: "token t" } }; + const r1 = await f("https://forgejo.corp.example/api/v1/a", init); + await f("https://forgejo.corp.example/api/v1/b", init); + assert.equal(await r1.text(), "x"); + assert.deepEqual(calls[0], ["https://forgejo.corp.example/api/v1/a", init]); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(logs.length, 1); + assert.match(logs[0]!, /would refuse.*forgejo\.corp\.example.*private_address/); + }); + + it("off: no resolver call, no log", async () => { + const f = createForgeFetch({ + config: () => ({ mode: "off", allow: [] }), + resolve: noResolve, + passthrough: (async () => new Response("y")) as typeof fetch, + log: () => assert.fail("logged"), + }); + assert.equal(await (await f("http://169.254.169.254/")).text(), "y"); + }); + + it("default env (no flag) uses the replaced global fetch unchanged", async () => { + const real = globalThis.fetch; + let got: unknown; + globalThis.fetch = (async (u: unknown) => { + got = u; + return new Response("[]"); + }) as typeof fetch; + const prev = process.env.SHIP_SAFE_FETCH; + delete process.env.SHIP_SAFE_FETCH; + try { + const ref = parseRepoUrl("http://127.0.0.1:1/owner/repo"); + assert.equal(await findOpenPullRequest({ ref, token: "t", head: "h", owner: "o" }), null); + assert.match(String(got), /^http:\/\/127\.0\.0\.1:1\/api\/v1\/repos\/owner\/repo\/pulls/); + } finally { + globalThis.fetch = real; + if (prev !== undefined) process.env.SHIP_SAFE_FETCH = prev; + } + }); +}); diff --git a/src/forge-egress.ts b/src/forge-egress.ts new file mode 100644 index 0000000..a9aa7d0 --- /dev/null +++ b/src/forge-egress.ts @@ -0,0 +1,236 @@ +/** + * Wires src/safe-fetch.ts to the forge fetches (programme S01/S04): the + * production pinned fetch, the operator allow-list, and the flag. + * + * Why this exists. A repository URL typed into the setup form (or carried by a + * project, a delivery or an imported issue) becomes `${ref.base}/api/v1/...` + * and is fetched with the forge token attached. Nothing checked where `base` + * points: `http://169.254.169.254/x/y`, a loopback admin port or the tailnet + * all received a request with the token. safe-fetch.ts decides; this module + * makes the decision bite and keeps it from breaking the legitimate case. + * + * SELF-HOSTED FORGES ARE LEGITIMATE. Ship's primary forge is a Forgejo on a + * private network, usually `http://forgejo:3000` or `https://git.lan`: exactly + * what the default policy refuses. So enforcement has an operator allow-list + * (`SHIP_SAFE_FETCH_ALLOW=host1,host2:3000`), and a listed host is trusted by + * NAME: private addresses, http, any port and single-label names pass. Cloud + * metadata and link-local addresses stay refused even for a listed host. + * + * MODES (`SHIP_SAFE_FETCH`): + * unset / "shadow" default. The request is untouched; what enforcement WOULD + * have refused is logged (once per host and reason). The + * check runs beside the request, never in front of it. + * "on" enforce: refuse, validate every redirect hop, connect to + * the validated address only. + * "off" no check at all, not even the shadow lookup. + * + * The injected-fetch path (`fetchImpl` arguments, tests) is not touched: only + * the DEFAULT forge fetch goes through here. + */ +import { lookup as dnsLookup } from "node:dns/promises"; +import { Agent, fetch as undiciFetch } from "undici"; +import { classifyAddress, validateRetrievalTarget, type Pin, type TargetDecision } from "./safe-fetch.js"; + +export type EgressMode = "off" | "shadow" | "enforce"; + +export interface AllowEntry { + host: string; + /** Undefined: any port. */ + port?: number; +} + +export interface EgressConfig { + mode: EgressMode; + allow: AllowEntry[]; +} + +export function parseAllowList(text: string | undefined): AllowEntry[] { + const out: AllowEntry[] = []; + for (const raw of (text ?? "").split(",")) { + const item = raw.trim().toLowerCase(); + if (item === "") continue; + const m = /^(\[[0-9a-f:.]+\]|[^:]+)(?::(\d{1,5}))?$/.exec(item); + if (m === null) continue; + const host = m[1]!.replace(/^\[|\]$/g, "").replace(/\.$/, ""); + out.push(m[2] === undefined ? { host } : { host, port: Number(m[2]) }); + } + return out; +} + +export function egressConfig(env: NodeJS.ProcessEnv = process.env): EgressConfig { + const flag = (env.SHIP_SAFE_FETCH ?? "").trim().toLowerCase(); + const mode: EgressMode = flag === "on" ? "enforce" : flag === "off" ? "off" : "shadow"; + return { mode, allow: parseAllowList(env.SHIP_SAFE_FETCH_ALLOW) }; +} + +export type Resolver = (hostname: string) => Promise; + +export const systemResolve: Resolver = async (hostname) => + (await dnsLookup(hostname, { all: true, verbatim: true })).map((a) => a.address); + +export type EgressDecision = + | { allowed: true; url: URL; pin: Pin; via: "policy" | "allowlist" } + | { allowed: false; reason: string; detail: string }; + +function bareHost(url: URL): string { + return url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); +} + +function allowListed(url: URL, allow: readonly AllowEntry[]): boolean { + const host = bareHost(url); + const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port); + return allow.some((e) => e.host === host && (e.port === undefined || e.port === port)); +} + +/** The decision for one hop: operator allow-list first, then the default policy. */ +export async function decideEgress(input: string | URL, config: EgressConfig, resolve: Resolver = systemResolve): Promise { + let url: URL; + try { + url = new URL(typeof input === "string" ? input.trim() : input.href); + } catch { + return { allowed: false, reason: "invalid_url", detail: "does not parse" }; + } + if (allowListed(url, config.allow)) { + if (url.protocol !== "https:" && url.protocol !== "http:") return { allowed: false, reason: "scheme", detail: `scheme ${url.protocol} not allowed` }; + if (url.username !== "" || url.password !== "") return { allowed: false, reason: "userinfo", detail: "credentials in URL" }; + const host = bareHost(url); + let addresses: string[]; + if (classifyAddress(host) !== null) addresses = [host]; + else { + try { + addresses = await resolve(host); + } catch (error) { + return { allowed: false, reason: "resolve_failed", detail: error instanceof Error ? error.message : String(error) }; + } + } + if (addresses.length === 0) return { allowed: false, reason: "no_addresses", detail: "name resolved to nothing" }; + for (const address of addresses) { + const c = classifyAddress(address); + if (c === null) return { allowed: false, reason: "bad_address", detail: `unparseable resolved address ${address}` }; + // Trusting a name does not extend to metadata or link-local answers. + if (c.cls === "linklocal" || address === "100.100.100.200") return { allowed: false, reason: "private_address", detail: `${host} resolves to ${address} (${c.cls})` }; + } + const first = classifyAddress(addresses[0]!)!; + return { allowed: true, url, pin: { address: addresses[0]!, family: first.family, hostname: host }, via: "allowlist" }; + } + const decision: TargetDecision = await validateRetrievalTarget(url, { resolve }); + if (!decision.allowed) return { allowed: false, reason: decision.reason, detail: decision.detail }; + return { allowed: true, url: decision.url, pin: decision.pin, via: "policy" }; +} + +/** + * A fetch that connects to `pin.address` whatever the name resolves to at + * connect time, keeping the URL's hostname for SNI and Host. One short-lived + * Agent per request: the pin is per request, so it cannot be a shared one. + * Matches `RetrieveDeps.fetch`, so retrieveUntrusted can use it directly. + */ +export async function pinnedFetch(url: string, init: RequestInit, pin: Pin): Promise { + const agent = new Agent({ + connect: { + lookup: ((_hostname: string, options: { all?: boolean }, callback: (...args: unknown[]) => void) => { + if (options?.all === true) callback(null, [{ address: pin.address, family: pin.family }]); + else callback(null, pin.address, pin.family); + }) as never, + }, + }); + try { + return (await undiciFetch(url, { ...(init as Parameters[1]), dispatcher: agent })) as unknown as Response; + } finally { + // close() is graceful: the in-flight body finishes, then the sockets go. + void agent.close().catch(() => undefined); + } +} + +export interface EgressDeps { + config?: () => EgressConfig; + resolve?: Resolver; + /** The network call for an allowed hop. Default: pinnedFetch. */ + fetch?: (url: string, init: RequestInit, pin: Pin) => Promise; + /** The untouched path (off and shadow). Read at call time: tests replace globalThis.fetch. */ + passthrough?: typeof fetch; + log?: (line: string) => void; + maxRedirects?: number; +} + +export class EgressDenied extends TypeError { + constructor( + readonly reason: string, + detail: string, + ) { + super(`forge request refused by SHIP_SAFE_FETCH: ${reason}: ${detail}`); + } +} + +const shadowSeen = new Set(); + +/** Test hook: shadow logging dedupes per process. */ +export function resetShadowLog(): void { + shadowSeen.clear(); +} + +const REDIRECTS = new Set([301, 302, 303, 307, 308]); + +/** + * The forge fetch. Same signature as `fetch`; git.ts and forge-state.ts use it + * where they used the global. + */ +export function createForgeFetch(deps: EgressDeps = {}): typeof fetch { + const log = deps.log ?? ((line: string) => console.warn(line)); + const resolve = deps.resolve ?? systemResolve; + return (async (input: string | URL | Request, init?: RequestInit): Promise => { + const config = (deps.config ?? egressConfig)(); + const passthrough = deps.passthrough ?? ((...a: Parameters) => globalThis.fetch(...a)); + if (config.mode === "off") return passthrough(input as never, init); + const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + + if (config.mode === "shadow") { + void (async () => { + const d = await decideEgress(href, config, resolve); + if (d.allowed) return; + let host = "?"; + try { + host = new URL(href).host; + } catch { + // keep "?" + } + const key = `${host} ${d.reason}`; + if (shadowSeen.has(key)) return; + shadowSeen.add(key); + log(`[safe-fetch:shadow] would refuse forge request to ${host}: ${d.reason} (${d.detail}). Set SHIP_SAFE_FETCH_ALLOW=${host} if this is a legitimate forge, then SHIP_SAFE_FETCH=on.`); + })().catch(() => undefined); + return passthrough(input as never, init); + } + + if (typeof input !== "string" && !(input instanceof URL)) throw new TypeError("forge egress needs a URL string"); + const doFetch = deps.fetch ?? pinnedFetch; + const wanted = init?.redirect ?? "follow"; + const maxRedirects = deps.maxRedirects ?? 3; + let current = href; + let currentInit: RequestInit = { ...init, redirect: "manual" }; + const origin0 = new URL(href).origin; + for (let hop = 0; ; hop++) { + const d = await decideEgress(current, config, resolve); + if (!d.allowed) throw new EgressDenied(d.reason, `hop ${hop}: ${d.detail}`); + const response = await doFetch(d.url.href, currentInit, d.pin); + if (!REDIRECTS.has(response.status) || wanted === "manual") return response; + void response.body?.cancel().catch(() => undefined); + if (wanted === "error") throw new TypeError("unexpected redirect"); + const location = response.headers.get("location"); + if (location === null || location === "" || hop + 1 > maxRedirects) throw new EgressDenied("redirect", `hop ${hop}: missing Location or more than ${maxRedirects} redirects`); + current = new URL(location, d.url).href; + if (new URL(current).origin !== origin0) { + // Credentials never follow a redirect off the original origin. + const headers = new Headers(currentInit.headers); + headers.delete("authorization"); + currentInit = { ...currentInit, headers }; + } + if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentInit.method === "POST")) { + const { body: _body, ...rest } = currentInit; + currentInit = { ...rest, method: "GET" }; + } + } + }) as typeof fetch; +} + +/** The instance the forge code uses. */ +export const forgeFetch: typeof fetch = createForgeFetch(); diff --git a/src/forge-state.ts b/src/forge-state.ts index bddf0ba..52a6e54 100644 --- a/src/forge-state.ts +++ b/src/forge-state.ts @@ -1,4 +1,5 @@ import type { RepoRef } from "./git.js"; +import { forgeFetch } from "./forge-egress.js"; import { safeForDisplay } from "./redact.js"; export interface ForgeState { checkedAt: string; @@ -18,7 +19,7 @@ export async function readForgeState( ref: RepoRef, token: string, pr: number, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise { if (!Number.isSafeInteger(pr) || pr < 1) throw new Error("Invalid pull request number"); diff --git a/src/git.ts b/src/git.ts index a3a0149..f1b74e3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,6 +1,7 @@ import type { AgentExecutor } from "@neutron-build/agents"; import { frameUntrusted } from "./guard.js"; +import { forgeFetch } from "./forge-egress.js"; import { publishLimitsFromEnv, screenPublication } from "./publish-policy.js"; import type { PublishLimits, PublishScreen } from "./publish-policy.js"; @@ -404,7 +405,7 @@ export async function findOpenPullRequest(options: { fetchImpl?: typeof fetch; }): Promise { const { ref, token, head, owner } = options; - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const endpoint = ref.kind === "github" ? `https://api.github.com/repos/${ref.owner}/${ref.repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}` @@ -544,7 +545,7 @@ export async function openPullRequest(options: { fetchImpl?: typeof fetch; }): Promise { const { ref, token } = options; - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const draft = options.draft === true; const body = withTrailers(options.body, options.trailers); const title = draft && ref.kind !== "github" ? `WIP: ${options.title}` : options.title; @@ -591,7 +592,7 @@ export async function requestReviewers(options: { }): Promise { const { ref, token } = options; if (options.users.length === 0 && options.teams.length === 0) return; - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const endpoint = ref.kind === "github" ? `https://api.github.com/repos/${ref.owner}/${ref.repo}/pulls/${options.pr}/requested_reviewers` @@ -634,7 +635,7 @@ export async function updatePullRequestBody(options: { fetchImpl?: typeof fetch; }): Promise { const { ref, token } = options; - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const endpoint = ref.kind === "github" ? `https://api.github.com/repos/${ref.owner}/${ref.repo}/pulls/${options.pr}` @@ -671,7 +672,7 @@ export async function readPullRequestBody(options: { fetchImpl?: typeof fetch; }): Promise { const { ref, token } = options; - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const endpoint = ref.kind === "github" ? `https://api.github.com/repos/${ref.owner}/${ref.repo}/pulls/${options.pr}` @@ -724,7 +725,7 @@ export async function resolvePr( ref: RepoRef, token: string, pr: number, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, requireOpen = false, ): Promise { const endpoint = @@ -767,7 +768,7 @@ export async function setupRepoForPr( options: { ref: RepoRef; token: string; pr: number; headToken?: string; requireOpen?: boolean }, ): Promise { const { ref, token, pr } = options; - const checkout = await resolvePr(ref, token, pr, fetch, options.requireOpen); + const checkout = await resolvePr(ref, token, pr, forgeFetch, options.requireOpen); await cloneCredentialFree(executor, ref, token); await git(executor, 'git config user.name "Teploy Ship" && git config user.email "ship@teploy.dev"'); await git(executor, excludeCommand()); @@ -799,7 +800,7 @@ export async function commentOnPr( token: string, pr: number, body: string, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise { const endpoint = ref.kind === "github" @@ -831,7 +832,7 @@ export async function uploadPrAsset(options: { fetchImpl?: typeof fetch; }): Promise { const { ref, token, pr, name, bytes } = options; - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = options.fetchImpl ?? forgeFetch; if (ref.kind === "github") throw new Error("GitHub has no API for attaching a file to a pull request"); const form = new FormData(); form.append("attachment", new Blob([Buffer.from(bytes)], { type: name.endsWith(".webm") ? "video/webm" : "image/png" }), name); @@ -910,7 +911,7 @@ export async function listPrReviewComments( pr: number, options: { reviewId?: number; max?: number; fetchImpl?: typeof fetch } = {}, ): Promise { - const doFetch = options.fetchImpl ?? fetch; + const doFetch = options.fetchImpl ?? forgeFetch; const max = options.max ?? 50; const headers = { authorization: ref.kind === "github" ? `Bearer ${token}` : `token ${token}`, @@ -1055,7 +1056,7 @@ export async function readPullRequestState( ref: RepoRef, token: string, pr: number, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise { try { const response = await fetchImpl(pullEndpoint(ref, pr), { headers: forgeHeaders(ref, token) }); @@ -1095,7 +1096,7 @@ export async function mergePullRequestReconciled( token: string, pr: number, options: { method?: "squash" | "merge" | "rebase"; title?: string; message?: string } = {}, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise { const outcome = await mergePullRequest(ref, token, pr, options, fetchImpl); if (outcome.kind === "merged") return outcome; @@ -1138,7 +1139,7 @@ export async function mergePullRequest( token: string, pr: number, options: { method?: "squash" | "merge" | "rebase"; title?: string; message?: string } = {}, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise { const method = options.method ?? "squash"; const github = ref.kind === "github"; @@ -1269,7 +1270,7 @@ export async function markPullRequestReady( ref: RepoRef, token: string, pr: number, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise<{ ok: boolean; reason?: string }> { try { const current = await fetchImpl(pullEndpoint(ref, pr), { headers: forgeHeaders(ref, token) }); @@ -1312,7 +1313,7 @@ export async function closePullRequest( ref: RepoRef, token: string, pr: number, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = forgeFetch, ): Promise<{ ok: boolean; reason?: string }> { try { const response = await fetchImpl(pullEndpoint(ref, pr), {